feat: Multiple Features, Accessibility, Security, and UX Improvements #59

Merged
naomi merged 27 commits from feat/polish into main 2026-02-20 01:51:25 -08:00
11 changed files with 291 additions and 84 deletions
Showing only changes of commit 6f4c3bd41a - Show all commits
+2 -1
View File
@@ -1,5 +1,6 @@
<a href="#main-content" class="skip-link">Skip to main content</a>
<app-header></app-header>
<main class="main-content">
<main id="main-content" class="main-content" tabindex="-1">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
@@ -12,24 +12,24 @@ import { CommonModule } from '@angular/common';
standalone: true,
imports: [CommonModule],
template: `
<footer class="footer">
<footer class="footer" role="contentinfo">
<div class="footer-content">
<div class="cta-section">
<div class="cta-card discord">
<h3>Join the Community</h3>
<section class="cta-card discord" aria-labelledby="discord-heading">
<h2 id="discord-heading">Join the Community</h2>
<p>Want to chat about what we're reading, playing, or listening to? Got recommendations? Come hang out!</p>
<a href="https://chat.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-discord">
Join our Discord
Join our Discord<span class="sr-only"> (opens in new window)</span>
</a>
</div>
</section>
<div class="cta-card donate">
<h3>Support Naomi</h3>
<section class="cta-card donate" aria-labelledby="donate-heading">
<h2 id="donate-heading">Support Naomi</h2>
<p>Enjoying the vibes? Your support helps keep the servers running and the coffee flowing!</p>
<a href="https://donate.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-donate">
Buy us a coffee
Buy us a coffee<span class="sr-only"> (opens in new window)</span>
</a>
</div>
</section>
</div>
<div class="footer-bottom">
@@ -73,7 +73,7 @@ import { CommonModule } from '@angular/common';
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.cta-card h3 {
.cta-card h2 {
margin: 0 0 0.75rem 0;
font-size: 1.25rem;
color: var(--witch-moon);
@@ -131,6 +131,18 @@ import { CommonModule } from '@angular/common';
font-size: 0.9rem;
color: var(--witch-lavender);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`]
})
export class FooterComponent {
@@ -503,39 +503,49 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</form>
}
<div class="search-section">
<div class="search-section" role="search" aria-label="Game search and filters">
<label for="game-search" class="sr-only">Search games</label>
<input
type="text"
type="search"
id="game-search"
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
name="search"
placeholder="Search by title, platform, or notes..."
class="search-input"
aria-label="Search by title, platform, or notes"
>
<button
(click)="toggleFilters()"
class="btn btn-secondary btn-sm"
[attr.aria-expanded]="showFilters()"
aria-controls="advanced-filters"
type="button"
>
<button (click)="toggleFilters()" class="btn btn-secondary btn-sm">
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
@if (selectedTags().length > 0) {
({{ selectedTags().length }})
<span aria-label="{{ selectedTags().length }} active filters">({{ selectedTags().length }})</span>
}
</button>
@if (searchQuery() || selectedTags().length > 0) {
<button (click)="clearFilters()" class="btn btn-secondary btn-sm">
<button (click)="clearFilters()" class="btn btn-secondary btn-sm" type="button">
Clear All Filters
</button>
}
</div>
@if (showFilters()) {
<div class="advanced-filters">
<div class="advanced-filters" id="advanced-filters" role="region" aria-label="Advanced filters">
<div class="filter-group">
<h4>Filter by Tags</h4>
<div class="tags-filter">
<h3>Filter by Tags</h3>
<div class="tags-filter" role="group" aria-label="Tag filters">
@for (tag of allTags(); track tag) {
<label class="tag-checkbox">
<input
type="checkbox"
[checked]="selectedTags().includes(tag)"
(change)="toggleTag(tag)"
[attr.aria-label]="'Filter by ' + tag"
>
<span>{{ tag }}</span>
</label>
@@ -548,39 +558,49 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
}
<div class="filters">
<div class="filters" role="group" aria-label="Filter games by status">
<button
(click)="setFilter('all')"
[class.active]="statusFilter() === 'all'"
[attr.aria-pressed]="statusFilter() === 'all'"
class="filter-btn"
type="button"
>
All ({{ games().length }})
</button>
<button
(click)="setFilter(GameStatus.playing)"
[class.active]="statusFilter() === GameStatus.playing"
[attr.aria-pressed]="statusFilter() === GameStatus.playing"
class="filter-btn"
type="button"
>
Playing ({{ playingCount() }})
</button>
<button
(click)="setFilter(GameStatus.completed)"
[class.active]="statusFilter() === GameStatus.completed"
[attr.aria-pressed]="statusFilter() === GameStatus.completed"
class="filter-btn"
type="button"
>
Completed ({{ completedCount() }})
</button>
<button
(click)="setFilter(GameStatus.backlog)"
[class.active]="statusFilter() === GameStatus.backlog"
[attr.aria-pressed]="statusFilter() === GameStatus.backlog"
class="filter-btn"
type="button"
>
Backlog ({{ backlogCount() }})
</button>
<button
(click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired"
[attr.aria-pressed]="statusFilter() === GameStatus.retired"
class="filter-btn"
type="button"
>
Retired ({{ retiredCount() }})
</button>
@@ -1061,6 +1081,18 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
input[type="file"]:hover {
border-color: #10b981;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`]
})
export class GamesListComponent implements OnInit {
@@ -6,7 +6,7 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { RouterModule, Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { ApiService } from '../../services/api.service';
@@ -16,58 +16,67 @@ import { ApiService } from '../../services/api.service';
imports: [CommonModule, RouterModule],
template: `
<header class="header">
<nav class="navbar">
<nav class="navbar" aria-label="Main navigation">
<div class="nav-brand">
<img src="/assets/icons/icon-72x72.png" alt="Naomi's Library" class="brand-icon" />
<img src="/assets/icons/icon-72x72.png" alt="" class="brand-icon" role="presentation" />
<h1><a routerLink="/">Naomi's Library</a></h1>
@if (version()) {
<span class="version">v{{ version() }}</span>
<span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
}
</div>
<ul class="nav-links">
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
<li><a routerLink="/shows" routerLinkActive="active">Shows</a></li>
<li><a routerLink="/manga" routerLinkActive="active">Manga</a></li>
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
<ul class="nav-links" role="list">
<li><a routerLink="/games" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/games') ? 'page' : null">Games</a></li>
<li><a routerLink="/books" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/books') ? 'page' : null">Books</a></li>
<li><a routerLink="/music" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/music') ? 'page' : null">Music</a></li>
<li><a routerLink="/shows" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/shows') ? 'page' : null">Shows</a></li>
<li><a routerLink="/manga" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/manga') ? 'page' : null">Manga</a></li>
<li><a routerLink="/art" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/art') ? 'page' : null">Art</a></li>
</ul>
<div class="auth-section">
@if (authService.user(); as user) {
<div class="user-menu">
@if (user.avatar) {
<img
[src]="user.avatar"
[alt]="user.username"
class="user-avatar"
<button
class="user-avatar-button"
[attr.aria-label]="'User menu for ' + user.username"
[attr.aria-expanded]="showDropdown()"
aria-haspopup="true"
(click)="toggleDropdown()"
(keyup.enter)="toggleDropdown()"
(keyup.space)="toggleDropdown()"
tabindex="0"
role="button"
/>
(keydown.escape)="closeDropdown()"
>
<img
[src]="user.avatar"
[alt]="'Avatar for ' + user.username"
class="user-avatar"
/>
</button>
}
@if (showDropdown()) {
<div class="dropdown-menu">
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
<a routerLink="/activity" class="dropdown-item" (click)="closeDropdown()">📰 Activity Feed</a>
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">️ About</a>
<div
class="dropdown-menu"
role="menu"
aria-label="User menu"
(keydown.escape)="closeDropdown()"
>
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" role="menuitem" (click)="closeDropdown()">My Profile</a>
<a routerLink="/settings" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Settings</a>
<a routerLink="/achievements" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">🏆</span> Achievements</a>
<a routerLink="/leaderboard" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">🏆</span> Leaderboard</a>
<a routerLink="/activity" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">📰</span> Activity Feed</a>
<a routerLink="/about" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">️</span> About</a>
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
<a routerLink="/my-suggestions" class="dropdown-item" role="menuitem" (click)="closeDropdown()">My Suggestions</a>
}
<a routerLink="/my-likes" class="dropdown-item" (click)="closeDropdown()">My Likes</a>
<a routerLink="/my-likes" class="dropdown-item" role="menuitem" (click)="closeDropdown()">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</a>
<a routerLink="/admin/reports" class="dropdown-item" (click)="closeDropdown()">Reports</a>
<a routerLink="/admin/users" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Suggestions</a>
<a routerLink="/admin/reports" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Reports</a>
}
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button>
<button (click)="logout()" class="dropdown-item logout-btn" role="menuitem">Logout</button>
</div>
}
</div>
@@ -172,20 +181,35 @@ import { ApiService } from '../../services/api.service';
position: relative;
}
.user-avatar-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--witch-lavender);
transition: all 0.3s;
cursor: pointer;
}
.user-avatar:hover {
.user-avatar-button:hover .user-avatar,
.user-avatar-button:focus .user-avatar {
border-color: var(--witch-moon);
transform: scale(1.1);
}
.user-avatar-button:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-radius: 50%;
}
.dropdown-menu {
position: absolute;
top: 50px;
@@ -304,6 +328,7 @@ import { ApiService } from '../../services/api.service';
export class HeaderComponent implements OnInit {
authService = inject(AuthService);
private apiService = inject(ApiService);
private router = inject(Router);
version = signal<string | null>(null);
showDropdown = signal<boolean>(false);
@@ -314,6 +339,10 @@ export class HeaderComponent implements OnInit {
});
}
isCurrentRoute(route: string): boolean {
return this.router.url.startsWith(route);
}
toggleDropdown() {
this.showDropdown.update(v => !v);
}
@@ -17,8 +17,8 @@ import { ReportReason } from '@library/shared-types';
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1">
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="onClose()" tabindex="-1" role="presentation">
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true" tabindex="-1">
<div class="modal-header">
<h2 id="modal-title">{{ modalTitle() }}</h2>
<button
@@ -23,10 +23,11 @@ import { take } from 'rxjs';
[class.liked]="liked()"
[disabled]="loading() || !isAuthenticated()"
(click)="toggleLike()"
[title]="getTitle()"
[attr.aria-label]="getAriaLabel()"
[attr.aria-pressed]="liked()"
>
<span class="heart-icon">{{ liked() ? '❤️' : '🤍' }}</span>
<span class="like-count">{{ count() }}</span>
<span class="heart-icon" aria-hidden="true">{{ liked() ? '❤️' : '🤍' }}</span>
<span class="like-count" aria-label="{{ count() }} likes">{{ count() }}</span>
</button>
</div>
`,
@@ -164,4 +165,15 @@ export class LikeButtonComponent implements OnInit {
}
return this.liked() ? 'Unlike' : 'Like';
}
getAriaLabel(): string {
const count = this.count();
const likeText = count === 1 ? 'like' : 'likes';
if (!this.isAuthenticated()) {
return `Sign in to like. ${count} ${likeText}`;
}
return this.liked()
? `Unlike. ${count} ${likeText}`
: `Like. ${count} ${likeText}`;
}
}
@@ -12,39 +12,44 @@ import { CommonModule } from '@angular/common';
standalone: true,
imports: [CommonModule],
template: `
<div class="pagination-container">
<div class="pagination-info">
<nav class="pagination-container" aria-label="Pagination">
<div class="pagination-info" role="status" aria-live="polite">
Showing {{ startItem() }} - {{ endItem() }} of {{ totalItems }} items
</div>
<div class="pagination-controls">
<div class="pagination-controls" role="navigation" aria-label="Page navigation">
<button
(click)="onPageChange(1)"
[disabled]="currentPage === 1"
class="btn btn-sm pagination-btn"
title="First page"
aria-label="Go to first page"
type="button"
>
<span aria-hidden="true">⟪</span>
</button>
<button
(click)="onPageChange(currentPage - 1)"
[disabled]="currentPage === 1"
class="btn btn-sm pagination-btn"
title="Previous page"
aria-label="Go to previous page"
type="button"
>
<span aria-hidden="true">←</span>
</button>
<div class="page-numbers">
<div class="page-numbers" role="group" aria-label="Page numbers">
@for (page of pageNumbers(); track page) {
@if (page === '...') {
<span class="page-ellipsis">{{ page }}</span>
<span class="page-ellipsis" aria-hidden="true">{{ page }}</span>
} @else {
<button
(click)="onPageChange(+page)"
[class.active]="currentPage === +page"
[attr.aria-current]="currentPage === +page ? 'page' : null"
[attr.aria-label]="'Go to page ' + page"
class="btn btn-sm pagination-btn page-number"
type="button"
>
{{ page }}
</button>
@@ -56,18 +61,20 @@ import { CommonModule } from '@angular/common';
(click)="onPageChange(currentPage + 1)"
[disabled]="currentPage === totalPages()"
class="btn btn-sm pagination-btn"
title="Next page"
aria-label="Go to next page"
type="button"
>
<span aria-hidden="true">→</span>
</button>
<button
(click)="onPageChange(totalPages())"
[disabled]="currentPage === totalPages()"
class="btn btn-sm pagination-btn"
title="Last page"
aria-label="Go to last page"
type="button"
>
<span aria-hidden="true">⟫</span>
</button>
</div>
@@ -78,6 +85,7 @@ import { CommonModule } from '@angular/common';
[value]="pageSize"
(change)="onPageSizeChange($event)"
class="page-size-select"
aria-label="Select number of items per page"
>
<option value="10">10</option>
<option value="25">25</option>
@@ -85,7 +93,7 @@ import { CommonModule } from '@angular/common';
<option value="100">100</option>
</select>
</div>
</div>
</nav>
`,
styles: [`
.pagination-container {
@@ -79,6 +79,24 @@
transform: scale(1.2);
}
.toast-close:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-radius: 4px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@keyframes slideIn {
from {
transform: translateX(400px);
@@ -4,17 +4,21 @@
@author Naomi Carrigan
-->
<div class="toast-container">
<div
class="toast-container"
role="region"
aria-label="Notifications"
aria-live="polite"
aria-atomic="false"
>
@for (toast of toastService.toastList(); track toast.id) {
<div
class="toast toast-{{ toast.type }}"
(click)="toastService.remove(toast.id)"
(keyup.enter)="toastService.remove(toast.id)"
(keyup.space)="toastService.remove(toast.id)"
tabindex="0"
role="button"
[attr.role]="toast.type === 'error' ? 'alert' : 'status'"
[attr.aria-live]="toast.type === 'error' ? 'assertive' : 'polite'"
aria-atomic="true"
>
<div class="toast-icon">
<div class="toast-icon" aria-hidden="true">
@switch (toast.type) {
@case ('error') { ❌ }
@case ('success') { ✅ }
@@ -22,8 +26,16 @@
@case ('warning') { ⚠️ }
}
</div>
<div class="toast-message">{{ toast.message }}</div>
<button class="toast-close" (click)="toastService.remove(toast.id)">×</button>
<div class="toast-message">
<span class="sr-only">{{ getTypeLabel(toast.type) }}:</span>
{{ toast.message }}
</div>
<button
class="toast-close"
(click)="toastService.remove(toast.id)"
[attr.aria-label]="'Dismiss ' + getTypeLabel(toast.type) + ' notification'"
type="button"
>×</button>
</div>
}
</div>
@@ -15,4 +15,14 @@ import { ToastService } from '../../services/toast.service';
})
export class ToastComponent {
public toastService = inject(ToastService);
getTypeLabel(type: string): string {
switch (type) {
case 'error': return 'Error';
case 'success': return 'Success';
case 'warning': return 'Warning';
case 'info': return 'Information';
default: return 'Notification';
}
}
}
+73
View File
@@ -142,3 +142,76 @@ button {
::-webkit-scrollbar-thumb:hover {
background: var(--witch-plum);
}
// Skip to main content link
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--witch-rose);
color: var(--witch-moon);
padding: 0.75rem 1.5rem;
text-decoration: none;
font-weight: 600;
border-radius: 0 0 4px 0;
z-index: 10000;
transition: top 0.3s;
}
.skip-link:focus {
top: 0;
}
// Enhanced focus styles for keyboard navigation
*:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-radius: 2px;
}
// Remove default focus outline when focus-visible is used
*:focus:not(:focus-visible) {
outline: none;
}
// Button focus styles
button:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
}
// Link focus styles
a:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
text-decoration: underline;
}
// Form input focus styles
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-color: var(--witch-rose);
}
// Ensure sufficient contrast for focus indicators
@media (prefers-contrast: high) {
*:focus-visible {
outline-width: 4px;
outline-offset: 3px;
}
}
// Reduced motion preferences
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}