feat: pagination

This commit is contained in:
2026-02-04 20:17:04 -08:00
parent b9f33bc055
commit ca288eaac4
10 changed files with 696 additions and 30 deletions
@@ -4,17 +4,18 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SuggestionService } from '../../services/suggestion.service';
import { AuthService } from '../../services/auth.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
@Component({
selector: 'app-admin-suggestions',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -65,8 +66,16 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
<p>No suggestions found.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredSuggestions()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="suggestions-list">
@for (suggestion of filteredSuggestions(); track suggestion.id) {
@for (suggestion of paginatedSuggestions(); track suggestion.id) {
<div class="suggestion-card" [class]="'status-' + suggestion.status.toLowerCase()">
<div class="suggestion-header">
<div class="badges">
@@ -185,6 +194,14 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredSuggestions()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
}
@@ -532,19 +549,32 @@ export class AdminSuggestionsComponent implements OnInit {
decliningsuggestion = signal<Suggestion | null>(null);
declineReason = '';
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
SuggestionStatus = SuggestionStatus;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
filteredSuggestions = () => {
filteredSuggestions = computed(() => {
const filter = this.statusFilter();
if (filter === 'all') {
return this.suggestions();
}
return this.suggestions().filter(s => s.status === filter);
};
});
paginatedSuggestions = computed(() => {
const suggestions = this.filteredSuggestions();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return suggestions.slice(start, end);
});
totalFilteredSuggestions = computed(() => this.filteredSuggestions().length);
ngOnInit() {
if (this.authService.isAdmin()) {
@@ -568,6 +598,19 @@ export class AdminSuggestionsComponent implements OnInit {
setFilter(filter: 'all' | SuggestionStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: SuggestionStatus): string {
@@ -4,17 +4,18 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { UserService } from '../../services/user.service';
import { AuthService } from '../../services/auth.service';
import { PaginationComponent } from '../shared/pagination.component';
import { User } from '@library/shared-types';
@Component({
selector: 'app-admin-users',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, PaginationComponent],
template: `
<div class="admin-container">
<h2>User Management</h2>
@@ -24,8 +25,16 @@ import { User } from '@library/shared-types';
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalUsers()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="users-list">
@for (user of users(); track user.id) {
@for (user of paginatedUsers(); track user.id) {
<div class="user-card" [class.banned]="user.isBanned">
<div class="user-info">
@if (user.avatar) {
@@ -70,6 +79,14 @@ import { User } from '@library/shared-types';
<p class="no-users">No users found.</p>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalUsers()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -270,6 +287,18 @@ export class AdminUsersComponent implements OnInit {
loading = signal(true);
error = signal<string | null>(null);
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
paginatedUsers = computed(() => {
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return this.users().slice(start, end);
});
totalUsers = computed(() => this.users().length);
ngOnInit(): void {
if (!this.authService.isAdmin()) {
this.router.navigate(['/']);
@@ -320,4 +349,16 @@ export class AdminUsersComponent implements OnInit {
}
});
}
onPageChange(page: number): void {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number): void {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ArtService } from '../../services/art.service';
@@ -12,12 +12,13 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-art-gallery',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -337,8 +338,16 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
<p>No artwork in the gallery yet.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalArtPieces()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="gallery-grid">
@for (art of artPieces(); track art.id) {
@for (art of paginatedArtPieces(); track art.id) {
<div class="art-card">
<div class="art-image-container">
<img
@@ -465,6 +474,14 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalArtPieces()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
@if (lightboxArt()) {
@@ -1047,6 +1064,10 @@ export class ArtGalleryComponent implements OnInit {
editingArt = signal<Art | null>(null);
lightboxArt = signal<Art | null>(null);
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
@@ -1083,6 +1104,16 @@ export class ArtGalleryComponent implements OnInit {
editLinkTitle = '';
editLinkUrl = '';
// Computed properties for pagination
paginatedArtPieces = computed(() => {
const artPieces = this.artPieces();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return artPieces.slice(start, end);
});
totalArtPieces = computed(() => this.artPieces().length);
ngOnInit() {
this.loadArt();
}
@@ -1388,4 +1419,16 @@ export class ArtGalleryComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.');
}
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
}
@@ -12,12 +12,13 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-books-list',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -434,8 +435,16 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
<p>No books found in this category.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredBooks()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="books-grid">
@for (book of filteredBooks(); track book.id) {
@for (book of paginatedBooks(); track book.id) {
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
@if (book.coverImage) {
<img [src]="book.coverImage" [alt]="book.title" class="book-cover">
@@ -584,6 +593,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredBooks()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -1173,6 +1190,10 @@ export class BooksListComponent implements OnInit {
editingBook = signal<Book | null>(null);
statusFilter = signal<'all' | BookStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
@@ -1208,12 +1229,21 @@ export class BooksListComponent implements OnInit {
filteredBooks = computed(() => {
const filter = this.statusFilter();
if (filter === 'all') {
return this.books();
}
return this.books().filter(book => book.status === filter);
const books = filter === 'all'
? this.books()
: this.books().filter(book => book.status === filter);
return books;
});
paginatedBooks = computed(() => {
const books = this.filteredBooks();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return books.slice(start, end);
});
totalFilteredBooks = computed(() => this.filteredBooks().length);
newBook: Partial<CreateBookDto> = {
title: '',
author: '',
@@ -1254,6 +1284,19 @@ export class BooksListComponent implements OnInit {
setFilter(filter: 'all' | BookStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: BookStatus): string {
@@ -12,12 +12,13 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-games-list',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -398,8 +399,16 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<p>No games found in this category.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredGames()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="games-grid">
@for (game of filteredGames(); track game.id) {
@for (game of paginatedGames(); track game.id) {
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
@if (game.coverImage) {
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
@@ -537,6 +546,14 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredGames()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -1029,6 +1046,10 @@ export class GamesListComponent implements OnInit {
editingGame = signal<Game | null>(null);
statusFilter = signal<'all' | GameStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
@@ -1069,6 +1090,15 @@ export class GamesListComponent implements OnInit {
return this.games().filter(game => game.status === filter);
});
paginatedGames = computed(() => {
const games = this.filteredGames();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return games.slice(start, end);
});
totalFilteredGames = computed(() => this.filteredGames().length);
newGame: Partial<CreateGameDto> = {
title: '',
platform: '',
@@ -1108,6 +1138,19 @@ export class GamesListComponent implements OnInit {
setFilter(filter: 'all' | GameStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: GameStatus): string {
@@ -12,12 +12,13 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-manga-list',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -401,8 +402,16 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<p>No manga found in this category.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredManga()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="manga-grid">
@for (manga of filteredManga(); track manga.id) {
@for (manga of paginatedManga(); track manga.id) {
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
@if (manga.coverImage) {
<img [src]="manga.coverImage" [alt]="manga.title" class="manga-cover">
@@ -538,6 +547,14 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredManga()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -1036,6 +1053,10 @@ export class MangaListComponent implements OnInit {
editingManga = signal<Manga | null>(null);
statusFilter = signal<'all' | MangaStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
@@ -1072,6 +1093,15 @@ export class MangaListComponent implements OnInit {
return this.mangaList().filter(m => m.status === filter);
});
paginatedManga = computed(() => {
const manga = this.filteredManga();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return manga.slice(start, end);
});
totalFilteredManga = computed(() => this.filteredManga().length);
newManga: Partial<CreateMangaDto> = {
title: '',
author: '',
@@ -1111,6 +1141,19 @@ export class MangaListComponent implements OnInit {
setFilter(filter: 'all' | MangaStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: MangaStatus): string {
@@ -12,12 +12,13 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-music-list',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -463,8 +464,16 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<p>No music found with these filters.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredMusic()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="music-grid">
@for (music of filteredMusic(); track music.id) {
@for (music of paginatedMusic(); track music.id) {
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
@if (music.coverArt) {
<img [src]="music.coverArt" [alt]="music.title" class="music-cover">
@@ -620,6 +629,14 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredMusic()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -1244,6 +1261,10 @@ export class MusicListComponent implements OnInit {
typeFilter = signal<'all' | MusicType>('all');
statusFilter = signal<'all' | MusicStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
@@ -1299,6 +1320,15 @@ export class MusicListComponent implements OnInit {
return filtered;
});
paginatedMusic = computed(() => {
const music = this.filteredMusic();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return music.slice(start, end);
});
totalFilteredMusic = computed(() => this.filteredMusic().length);
newMusic: Partial<CreateMusicDto> = {
title: '',
artist: '',
@@ -1339,10 +1369,24 @@ export class MusicListComponent implements OnInit {
setTypeFilter(filter: 'all' | MusicType) {
this.typeFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
setStatusFilter(filter: 'all' | MusicStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getTypeLabel(type: MusicType): string {
@@ -4,16 +4,17 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SuggestionService } from '../../services/suggestion.service';
import { AuthService } from '../../services/auth.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
@Component({
selector: 'app-my-suggestions',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -64,8 +65,16 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</button>
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredSuggestions()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="suggestions-list">
@for (suggestion of filteredSuggestions(); track suggestion.id) {
@for (suggestion of paginatedSuggestions(); track suggestion.id) {
<div class="suggestion-card" [class]="'status-' + suggestion.status.toLowerCase()">
<div class="suggestion-header">
<span class="entity-badge" [class]="'entity-' + suggestion.entityType.toLowerCase()">
@@ -123,6 +132,14 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredSuggestions()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -314,19 +331,32 @@ export class MySuggestionsComponent implements OnInit {
loading = signal(true);
statusFilter = signal<'all' | SuggestionStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
SuggestionStatus = SuggestionStatus;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
filteredSuggestions = () => {
filteredSuggestions = computed(() => {
const filter = this.statusFilter();
if (filter === 'all') {
return this.suggestions();
}
return this.suggestions().filter(s => s.status === filter);
};
});
paginatedSuggestions = computed(() => {
const suggestions = this.filteredSuggestions();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return suggestions.slice(start, end);
});
totalFilteredSuggestions = computed(() => this.filteredSuggestions().length);
ngOnInit() {
if (this.authService.isAuthenticated()) {
@@ -350,6 +380,19 @@ export class MySuggestionsComponent implements OnInit {
setFilter(filter: 'all' | SuggestionStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: SuggestionStatus): string {
@@ -0,0 +1,280 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, Input, Output, EventEmitter, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-pagination',
standalone: true,
imports: [CommonModule],
template: `
<div class="pagination-container">
<div class="pagination-info">
Showing {{ startItem() }} - {{ endItem() }} of {{ totalItems }} items
</div>
<div class="pagination-controls">
<button
(click)="onPageChange(1)"
[disabled]="currentPage === 1"
class="btn btn-sm pagination-btn"
title="First page"
>
</button>
<button
(click)="onPageChange(currentPage - 1)"
[disabled]="currentPage === 1"
class="btn btn-sm pagination-btn"
title="Previous page"
>
</button>
<div class="page-numbers">
@for (page of pageNumbers(); track page) {
@if (page === '...') {
<span class="page-ellipsis">{{ page }}</span>
} @else {
<button
(click)="onPageChange(+page)"
[class.active]="currentPage === +page"
class="btn btn-sm pagination-btn page-number"
>
{{ page }}
</button>
}
}
</div>
<button
(click)="onPageChange(currentPage + 1)"
[disabled]="currentPage === totalPages()"
class="btn btn-sm pagination-btn"
title="Next page"
>
</button>
<button
(click)="onPageChange(totalPages())"
[disabled]="currentPage === totalPages()"
class="btn btn-sm pagination-btn"
title="Last page"
>
</button>
</div>
<div class="page-size-selector">
<label for="page-size">Items per page:</label>
<select
id="page-size"
[value]="pageSize"
(change)="onPageSizeChange($event)"
class="page-size-select"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
`,
styles: [`
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
flex-wrap: wrap;
gap: 1rem;
}
.pagination-info {
color: var(--witch-plum);
font-size: 0.9rem;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pagination-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--witch-lavender);
background: var(--witch-moon);
color: var(--witch-purple);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
min-width: 2.5rem;
}
.pagination-btn:hover:not(:disabled) {
background: var(--witch-lavender);
border-color: var(--witch-mauve);
transform: translateY(-1px);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-btn.active {
background: #8b6f47;
color: white;
border-color: #8b6f47;
}
.page-numbers {
display: flex;
align-items: center;
gap: 0.25rem;
}
.page-number {
min-width: 2.5rem;
}
.page-ellipsis {
padding: 0 0.5rem;
color: var(--witch-mauve);
}
.page-size-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-size-selector label {
font-size: 0.9rem;
color: var(--witch-plum);
}
.page-size-select {
padding: 0.25rem 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
background: var(--witch-moon);
color: var(--witch-purple);
cursor: pointer;
transition: border-color 0.2s;
}
.page-size-select:hover {
border-color: var(--witch-mauve);
}
.page-size-select:focus {
outline: none;
border-color: var(--witch-rose);
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
}
@media (max-width: 768px) {
.pagination-container {
justify-content: center;
}
.pagination-controls {
order: 2;
width: 100%;
justify-content: center;
}
.pagination-info {
order: 1;
width: 100%;
text-align: center;
}
.page-size-selector {
order: 3;
width: 100%;
justify-content: center;
}
}
`]
})
export class PaginationComponent {
@Input() currentPage = 1;
@Input() pageSize = 25;
@Input() totalItems = 0;
@Output() pageChange = new EventEmitter<number>();
@Output() pageSizeChange = new EventEmitter<number>();
totalPages = computed(() => Math.ceil(this.totalItems / this.pageSize));
startItem = computed(() => {
if (this.totalItems === 0) return 0;
return (this.currentPage - 1) * this.pageSize + 1;
});
endItem = computed(() => {
return Math.min(this.currentPage * this.pageSize, this.totalItems);
});
pageNumbers = computed(() => {
const total = this.totalPages();
const current = this.currentPage;
const pages: (number | string)[] = [];
if (total <= 7) {
// Show all pages if 7 or fewer
for (let i = 1; i <= total; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (current <= 3) {
// Near the beginning
for (let i = 2; i <= 5; i++) {
pages.push(i);
}
pages.push('...');
pages.push(total);
} else if (current >= total - 2) {
// Near the end
pages.push('...');
for (let i = total - 4; i <= total; i++) {
pages.push(i);
}
} else {
// In the middle
pages.push('...');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('...');
pages.push(total);
}
}
return pages;
});
onPageChange(page: number) {
if (page >= 1 && page <= this.totalPages() && page !== this.currentPage) {
this.pageChange.emit(page);
}
}
onPageSizeChange(event: Event) {
const target = event.target as HTMLSelectElement;
const newSize = parseInt(target.value, 10);
this.pageSizeChange.emit(newSize);
}
}
@@ -12,12 +12,13 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-shows-list',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PaginationComponent],
template: `
<div class="container">
<div class="header-section">
@@ -395,8 +396,16 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<p>No shows found in this category.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredShows()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="shows-grid">
@for (show of filteredShows(); track show.id) {
@for (show of paginatedShows(); track show.id) {
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
@if (show.coverImage) {
<img [src]="show.coverImage" [alt]="show.title" class="show-cover">
@@ -532,6 +541,14 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredShows()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
@@ -1029,6 +1046,10 @@ export class ShowsListComponent implements OnInit {
editingShow = signal<Show | null>(null);
statusFilter = signal<'all' | ShowStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
@@ -1066,6 +1087,15 @@ export class ShowsListComponent implements OnInit {
return this.shows().filter(show => show.status === filter);
});
paginatedShows = computed(() => {
const shows = this.filteredShows();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return shows.slice(start, end);
});
totalFilteredShows = computed(() => this.filteredShows().length);
newShow: Partial<CreateShowDto> = {
title: '',
type: ShowType.tvSeries,
@@ -1105,6 +1135,19 @@ export class ShowsListComponent implements OnInit {
setFilter(filter: 'all' | ShowStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: ShowStatus): string {