generated from nhcarrigan/template
feat: initial prototype works
I can log in and create a book! Woo!
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BooksService } from '../../services/books.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-books-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>My Book Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||
{{ showAddForm() ? 'Cancel' : 'Add Book' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddForm() && authService.isAdmin()) {
|
||||
<form (ngSubmit)="addBook()" class="add-form">
|
||||
<h3>Add New Book</h3>
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="newBook.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter book title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="author"
|
||||
[(ngModel)]="newBook.author"
|
||||
name="author"
|
||||
required
|
||||
placeholder="Enter author name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="isbn">ISBN</label>
|
||||
<input
|
||||
type="text"
|
||||
id="isbn"
|
||||
[(ngModel)]="newBook.isbn"
|
||||
name="isbn"
|
||||
placeholder="ISBN (optional)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="newBook.status" name="status" required>
|
||||
<option value="reading">Currently Reading</option>
|
||||
<option value="finished">Finished</option>
|
||||
<option value="toRead">To Read</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (0-5)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="newBook.rating"
|
||||
name="rating"
|
||||
min="0"
|
||||
max="5"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="newBook.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Your thoughts about the book..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Book</button>
|
||||
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
<button
|
||||
(click)="setFilter('all')"
|
||||
[class.active]="statusFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All ({{ books().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(BookStatus.reading)"
|
||||
[class.active]="statusFilter() === BookStatus.reading"
|
||||
class="filter-btn"
|
||||
>
|
||||
Reading ({{ getCountByStatus(BookStatus.reading) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(BookStatus.finished)"
|
||||
[class.active]="statusFilter() === BookStatus.finished"
|
||||
class="filter-btn"
|
||||
>
|
||||
Finished ({{ getCountByStatus(BookStatus.finished) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(BookStatus.toRead)"
|
||||
[class.active]="statusFilter() === BookStatus.toRead"
|
||||
class="filter-btn"
|
||||
>
|
||||
To Read ({{ getCountByStatus(BookStatus.toRead) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading books...</div>
|
||||
} @else if (filteredBooks().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No books found in this category.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="books-grid">
|
||||
@for (book of filteredBooks(); 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">
|
||||
} @else {
|
||||
<div class="book-cover placeholder">📚</div>
|
||||
}
|
||||
|
||||
<div class="book-info">
|
||||
<h3>{{ book.title }}</h3>
|
||||
<p class="author">by {{ book.author }}</p>
|
||||
|
||||
<span class="status status-{{ book.status }}">
|
||||
{{ getStatusLabel(book.status) }}
|
||||
</span>
|
||||
|
||||
@if (book.rating) {
|
||||
<div class="rating">
|
||||
@for (star of [1,2,3,4,5]; track star) {
|
||||
<span [class.filled]="star <= book.rating">★</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book.isbn) {
|
||||
<p class="isbn">ISBN: {{ book.isbn }}</p>
|
||||
}
|
||||
|
||||
@if (book.notes) {
|
||||
<p class="notes">{{ book.notes }}</p>
|
||||
}
|
||||
|
||||
@if (book.dateFinished) {
|
||||
<p class="date-finished">
|
||||
Finished: {{ formatDate(book.dateFinished) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (authService.isAdmin()) {
|
||||
<div class="actions">
|
||||
<button (click)="deleteBook(book)" class="btn btn-danger btn-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--witch-moon);
|
||||
color: var(--witch-purple);
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--witch-mauve);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.books-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.book-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.book-card.finished {
|
||||
opacity: 0.9;
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.book-cover.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--witch-lavender);
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.book-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: var(--witch-plum);
|
||||
font-style: italic;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-reading {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
.status-finished {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
.status-toRead {
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.rating {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.rating span {
|
||||
color: var(--witch-lavender);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.rating span.filled {
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.isbn {
|
||||
font-size: 0.8rem;
|
||||
color: var(--witch-mauve);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-plum);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-finished {
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-plum);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--witch-plum);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||
`]
|
||||
})
|
||||
export class BooksListComponent implements OnInit {
|
||||
booksService = inject(BooksService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
books = signal<Book[]>([]);
|
||||
loading = signal(true);
|
||||
showAddForm = signal(false);
|
||||
statusFilter = signal<'all' | BookStatus>('all');
|
||||
|
||||
// Expose BookStatus enum to template
|
||||
BookStatus = BookStatus;
|
||||
|
||||
newBook: Partial<CreateBookDto> = {
|
||||
title: '',
|
||||
author: '',
|
||||
isbn: '',
|
||||
status: BookStatus.toRead,
|
||||
rating: undefined,
|
||||
notes: ''
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.loadBooks();
|
||||
}
|
||||
|
||||
loadBooks() {
|
||||
this.loading.set(true);
|
||||
this.booksService.getAllBooks().subscribe({
|
||||
next: (books) => {
|
||||
this.books.set(books);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
filteredBooks() {
|
||||
const filter = this.statusFilter();
|
||||
if (filter === 'all') {
|
||||
return this.books();
|
||||
}
|
||||
return this.books().filter(book => book.status === filter);
|
||||
}
|
||||
|
||||
getCountByStatus(status: BookStatus): number {
|
||||
return this.books().filter(book => book.status === status).length;
|
||||
}
|
||||
|
||||
setFilter(filter: 'all' | BookStatus) {
|
||||
this.statusFilter.set(filter);
|
||||
}
|
||||
|
||||
getStatusLabel(status: BookStatus): string {
|
||||
switch (status) {
|
||||
case BookStatus.reading: return 'Currently Reading';
|
||||
case BookStatus.finished: return 'Finished';
|
||||
case BookStatus.toRead: return 'To Read';
|
||||
}
|
||||
}
|
||||
|
||||
toggleAddForm() {
|
||||
this.showAddForm.update(v => !v);
|
||||
if (!this.showAddForm()) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.newBook = {
|
||||
title: '',
|
||||
author: '',
|
||||
isbn: '',
|
||||
status: BookStatus.toRead,
|
||||
rating: undefined,
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
|
||||
addBook() {
|
||||
if (!this.newBook.title || !this.newBook.author || !this.newBook.status) return;
|
||||
|
||||
const bookToAdd: CreateBookDto = {
|
||||
title: this.newBook.title,
|
||||
author: this.newBook.author,
|
||||
isbn: this.newBook.isbn,
|
||||
status: this.newBook.status,
|
||||
rating: this.newBook.rating,
|
||||
notes: this.newBook.notes
|
||||
};
|
||||
|
||||
this.booksService.createBook(bookToAdd).subscribe(() => {
|
||||
this.loadBooks();
|
||||
this.toggleAddForm();
|
||||
});
|
||||
}
|
||||
|
||||
deleteBook(book: Book) {
|
||||
if (confirm(`Are you sure you want to delete "${book.title}"?`)) {
|
||||
this.booksService.deleteBook(book.id).subscribe(() => {
|
||||
this.loadBooks();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Theme overrides for games component
|
||||
.add-form {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
|
||||
&:hover {
|
||||
background: var(--witch-mauve);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
.game-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
}
|
||||
|
||||
&.completed {
|
||||
opacity: 0.8;
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
&-playing {
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
&-completed {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
&-backlog {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
|
||||
&:hover {
|
||||
background: var(--witch-plum);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GamesService } from '../../services/games.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-games-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>My Game Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||
{{ showAddForm() ? 'Cancel' : 'Add Game' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddForm() && authService.isAdmin()) {
|
||||
<form (ngSubmit)="addGame()" class="add-form">
|
||||
<h3>Add New Game</h3>
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="newGame.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter game title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="platform">Platform</label>
|
||||
<input
|
||||
type="text"
|
||||
id="platform"
|
||||
[(ngModel)]="newGame.platform"
|
||||
name="platform"
|
||||
placeholder="PC, PS5, Xbox, Switch, etc."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="newGame.status" name="status" required>
|
||||
<option value="playing">Currently Playing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="backlog">In Backlog</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (0-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="newGame.rating"
|
||||
name="rating"
|
||||
min="0"
|
||||
max="10"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="newGame.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Any thoughts about the game..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Game</button>
|
||||
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
<button
|
||||
(click)="setFilter('all')"
|
||||
[class.active]="statusFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All ({{ games().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(GameStatus.playing)"
|
||||
[class.active]="statusFilter() === GameStatus.playing"
|
||||
class="filter-btn"
|
||||
>
|
||||
Playing ({{ getCountByStatus(GameStatus.playing) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(GameStatus.completed)"
|
||||
[class.active]="statusFilter() === GameStatus.completed"
|
||||
class="filter-btn"
|
||||
>
|
||||
Completed ({{ getCountByStatus(GameStatus.completed) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(GameStatus.backlog)"
|
||||
[class.active]="statusFilter() === GameStatus.backlog"
|
||||
class="filter-btn"
|
||||
>
|
||||
Backlog ({{ getCountByStatus(GameStatus.backlog) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading games...</div>
|
||||
} @else if (filteredGames().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No games found in this category.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="games-grid">
|
||||
@for (game of filteredGames(); 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">
|
||||
}
|
||||
|
||||
<div class="game-info">
|
||||
<h3>{{ game.title }}</h3>
|
||||
@if (game.platform) {
|
||||
<p class="platform">{{ game.platform }}</p>
|
||||
}
|
||||
<span class="status status-{{ game.status }}">
|
||||
{{ getStatusLabel(game.status) }}
|
||||
</span>
|
||||
|
||||
@if (game.rating) {
|
||||
<div class="rating">
|
||||
⭐ {{ game.rating }}/10
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.notes) {
|
||||
<p class="notes">{{ game.notes }}</p>
|
||||
}
|
||||
|
||||
@if (authService.isAdmin()) {
|
||||
<div class="actions">
|
||||
<button (click)="deleteGame(game)" class="btn btn-danger btn-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
background: #f8f9fa;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e5e7eb;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.game-card.completed {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.game-cover {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.game-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.platform {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-playing { background: #fef3c7; color: #92400e; }
|
||||
.status-completed { background: #d1fae5; color: #065f46; }
|
||||
.status-backlog { background: #e0e7ff; color: #3730a3; }
|
||||
|
||||
.rating {
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-secondary { background: #6b7280; color: white; }
|
||||
.btn-danger { background: #ef4444; color: white; }
|
||||
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||
`]
|
||||
})
|
||||
export class GamesListComponent implements OnInit {
|
||||
gamesService = inject(GamesService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
games = signal<Game[]>([]);
|
||||
loading = signal(true);
|
||||
showAddForm = signal(false);
|
||||
statusFilter = signal<'all' | GameStatus>('all');
|
||||
|
||||
// Expose GameStatus enum to template
|
||||
GameStatus = GameStatus;
|
||||
|
||||
newGame: Partial<CreateGameDto> = {
|
||||
title: '',
|
||||
platform: '',
|
||||
status: GameStatus.backlog,
|
||||
rating: undefined,
|
||||
notes: ''
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.loadGames();
|
||||
}
|
||||
|
||||
loadGames() {
|
||||
this.loading.set(true);
|
||||
this.gamesService.getAllGames().subscribe({
|
||||
next: (games) => {
|
||||
this.games.set(games);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
filteredGames() {
|
||||
const filter = this.statusFilter();
|
||||
if (filter === 'all') {
|
||||
return this.games();
|
||||
}
|
||||
return this.games().filter(game => game.status === filter);
|
||||
}
|
||||
|
||||
getCountByStatus(status: GameStatus): number {
|
||||
return this.games().filter(game => game.status === status).length;
|
||||
}
|
||||
|
||||
setFilter(filter: 'all' | GameStatus) {
|
||||
this.statusFilter.set(filter);
|
||||
}
|
||||
|
||||
getStatusLabel(status: GameStatus): string {
|
||||
switch (status) {
|
||||
case GameStatus.playing: return 'Currently Playing';
|
||||
case GameStatus.completed: return 'Completed';
|
||||
case GameStatus.backlog: return 'In Backlog';
|
||||
}
|
||||
}
|
||||
|
||||
toggleAddForm() {
|
||||
this.showAddForm.update(v => !v);
|
||||
if (!this.showAddForm()) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.newGame = {
|
||||
title: '',
|
||||
platform: '',
|
||||
status: GameStatus.backlog,
|
||||
rating: undefined,
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
|
||||
addGame() {
|
||||
if (!this.newGame.title || !this.newGame.status) return;
|
||||
|
||||
const gameToAdd: CreateGameDto = {
|
||||
title: this.newGame.title,
|
||||
platform: this.newGame.platform,
|
||||
status: this.newGame.status,
|
||||
rating: this.newGame.rating,
|
||||
notes: this.newGame.notes
|
||||
};
|
||||
|
||||
this.gamesService.createGame(gameToAdd).subscribe(() => {
|
||||
this.loadGames();
|
||||
this.toggleAddForm();
|
||||
});
|
||||
}
|
||||
|
||||
deleteGame(game: Game) {
|
||||
if (confirm(`Are you sure you want to delete "${game.title}"?`)) {
|
||||
this.gamesService.deleteGame(game.id).subscribe(() => {
|
||||
this.loadGames();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
<header class="header">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<h1><a routerLink="/">Naomi's Library</a></h1>
|
||||
</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>
|
||||
</ul>
|
||||
|
||||
<div class="auth-section">
|
||||
@if (authService.user(); as user) {
|
||||
<span class="welcome">Welcome, {{ user.username }}!</span>
|
||||
@if (user.isAdmin) {
|
||||
<span class="admin-badge">Admin</span>
|
||||
}
|
||||
<button (click)="logout()" class="btn btn-secondary">Logout</button>
|
||||
} @else {
|
||||
<button (click)="login()" class="btn btn-primary">Login with Discord</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
`,
|
||||
styles: [`
|
||||
.header {
|
||||
background-color: var(--witch-purple);
|
||||
color: var(--witch-moon);
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
color: var(--witch-moon);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 2rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--witch-lavender);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
background-color: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-lavender);
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class HeaderComponent {
|
||||
authService = inject(AuthService);
|
||||
|
||||
login() {
|
||||
this.authService.login();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authService.logout().subscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GamesService } from '../../services/games.service';
|
||||
import { BooksService } from '../../services/books.service';
|
||||
import { MusicService } from '../../services/music.service';
|
||||
import { Game, GameStatus, Book, BookStatus, Music, MusicType } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Welcome to Naomi's Library</h1>
|
||||
<p class="tagline">A personal collection of games, books, and music</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<a routerLink="/games" class="stat-card games-card">
|
||||
<div class="icon">🎮</div>
|
||||
<div class="stat-info">
|
||||
<h3>Games</h3>
|
||||
<div class="count">{{ gamesCount() }}</div>
|
||||
<p>{{ currentlyPlayingCount() }} currently playing</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a routerLink="/books" class="stat-card books-card">
|
||||
<div class="icon">📚</div>
|
||||
<div class="stat-info">
|
||||
<h3>Books</h3>
|
||||
<div class="count">{{ booksCount() }}</div>
|
||||
<p>{{ currentlyReadingCount() }} currently reading</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a routerLink="/music" class="stat-card music-card">
|
||||
<div class="icon">🎵</div>
|
||||
<div class="stat-info">
|
||||
<h3>Music</h3>
|
||||
<div class="count">{{ musicCount() }}</div>
|
||||
<p>{{ albumsCount() }} albums, {{ singlesCount() }} singles</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="recent-section">
|
||||
<h2>Recent Additions</h2>
|
||||
|
||||
<div class="recent-grid">
|
||||
@if (recentGames().length > 0) {
|
||||
<div class="recent-category">
|
||||
<h3>🎮 Latest Games</h3>
|
||||
<ul class="recent-list">
|
||||
@for (game of recentGames(); track game.id) {
|
||||
<li>
|
||||
<a routerLink="/games">{{ game.title }}</a>
|
||||
@if (game.platform) {
|
||||
<span class="platform">({{ game.platform }})</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentBooks().length > 0) {
|
||||
<div class="recent-category">
|
||||
<h3>📚 Latest Books</h3>
|
||||
<ul class="recent-list">
|
||||
@for (book of recentBooks(); track book.id) {
|
||||
<li>
|
||||
<a routerLink="/books">{{ book.title }}</a>
|
||||
<span class="author">by {{ book.author }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentMusic().length > 0) {
|
||||
<div class="recent-category">
|
||||
<h3>🎵 Latest Music</h3>
|
||||
<ul class="recent-list">
|
||||
@for (music of recentMusic(); track music.id) {
|
||||
<li>
|
||||
<a routerLink="/music">{{ music.title }}</a>
|
||||
<span class="artist">by {{ music.artist }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.2rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-mauve);
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.3s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px var(--witch-shadow);
|
||||
background: var(--witch-moon);
|
||||
}
|
||||
|
||||
.games-card:hover { border-color: var(--witch-rose); }
|
||||
.books-card:hover { border-color: var(--witch-plum); }
|
||||
.music-card:hover { border-color: var(--witch-purple); }
|
||||
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.games-card .count { color: var(--witch-rose); }
|
||||
.books-card .count { color: var(--witch-plum); }
|
||||
.music-card .count { color: var(--witch-purple); }
|
||||
|
||||
.stat-info p {
|
||||
margin: 0;
|
||||
color: var(--witch-plum);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.recent-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.recent-section h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.recent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.recent-category h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recent-list li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--witch-lavender);
|
||||
}
|
||||
|
||||
.recent-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recent-list a {
|
||||
color: var(--witch-rose);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recent-list a:hover {
|
||||
color: var(--witch-plum);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.platform,
|
||||
.author,
|
||||
.artist {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-plum);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
gamesService = inject(GamesService);
|
||||
booksService = inject(BooksService);
|
||||
musicService = inject(MusicService);
|
||||
|
||||
games = signal<Game[]>([]);
|
||||
books = signal<Book[]>([]);
|
||||
music = signal<Music[]>([]);
|
||||
|
||||
ngOnInit() {
|
||||
// Load all data
|
||||
this.gamesService.getAllGames().subscribe(games => this.games.set(games));
|
||||
this.booksService.getAllBooks().subscribe(books => this.books.set(books));
|
||||
this.musicService.getAllMusic().subscribe(music => this.music.set(music));
|
||||
}
|
||||
|
||||
// Games stats
|
||||
gamesCount() {
|
||||
return this.games().length;
|
||||
}
|
||||
|
||||
currentlyPlayingCount() {
|
||||
return this.games().filter(g => g.status === GameStatus.playing).length;
|
||||
}
|
||||
|
||||
recentGames() {
|
||||
return this.games().slice(0, 5);
|
||||
}
|
||||
|
||||
// Books stats
|
||||
booksCount() {
|
||||
return this.books().length;
|
||||
}
|
||||
|
||||
currentlyReadingCount() {
|
||||
return this.books().filter(b => b.status === BookStatus.reading).length;
|
||||
}
|
||||
|
||||
recentBooks() {
|
||||
return this.books().slice(0, 5);
|
||||
}
|
||||
|
||||
// Music stats
|
||||
musicCount() {
|
||||
return this.music().length;
|
||||
}
|
||||
|
||||
albumsCount() {
|
||||
return this.music().filter(m => m.type === MusicType.album).length;
|
||||
}
|
||||
|
||||
singlesCount() {
|
||||
return this.music().filter(m => m.type === MusicType.single).length;
|
||||
}
|
||||
|
||||
recentMusic() {
|
||||
return this.music().slice(0, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MusicService } from '../../services/music.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-music-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>My Music Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||
{{ showAddForm() ? 'Cancel' : 'Add Music' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddForm() && authService.isAdmin()) {
|
||||
<form (ngSubmit)="addMusic()" class="add-form">
|
||||
<h3>Add New Music</h3>
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="newMusic.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Album/Single/EP title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="artist"
|
||||
[(ngModel)]="newMusic.artist"
|
||||
name="artist"
|
||||
required
|
||||
placeholder="Artist name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select id="type" [(ngModel)]="newMusic.type" name="type" required>
|
||||
<option value="album">Album</option>
|
||||
<option value="single">Single</option>
|
||||
<option value="ep">EP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="newMusic.status" name="status" required>
|
||||
<option value="listening">Currently Listening</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="wantToListen">Want to Listen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (0-5)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="newMusic.rating"
|
||||
name="rating"
|
||||
min="0"
|
||||
max="5"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="newMusic.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Your thoughts about this music..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Music</button>
|
||||
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<strong>Type:</strong>
|
||||
<button
|
||||
(click)="setTypeFilter('all')"
|
||||
[class.active]="typeFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
(click)="setTypeFilter(MusicType.album)"
|
||||
[class.active]="typeFilter() === MusicType.album"
|
||||
class="filter-btn"
|
||||
>
|
||||
Albums ({{ getCountByType(MusicType.album) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setTypeFilter(MusicType.single)"
|
||||
[class.active]="typeFilter() === MusicType.single"
|
||||
class="filter-btn"
|
||||
>
|
||||
Singles ({{ getCountByType(MusicType.single) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setTypeFilter(MusicType.ep)"
|
||||
[class.active]="typeFilter() === MusicType.ep"
|
||||
class="filter-btn"
|
||||
>
|
||||
EPs ({{ getCountByType(MusicType.ep) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<strong>Status:</strong>
|
||||
<button
|
||||
(click)="setStatusFilter('all')"
|
||||
[class.active]="statusFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
(click)="setStatusFilter(MusicStatus.listening)"
|
||||
[class.active]="statusFilter() === MusicStatus.listening"
|
||||
class="filter-btn"
|
||||
>
|
||||
Listening ({{ getCountByStatus(MusicStatus.listening) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setStatusFilter(MusicStatus.completed)"
|
||||
[class.active]="statusFilter() === MusicStatus.completed"
|
||||
class="filter-btn"
|
||||
>
|
||||
Completed ({{ getCountByStatus(MusicStatus.completed) }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setStatusFilter(MusicStatus.wantToListen)"
|
||||
[class.active]="statusFilter() === MusicStatus.wantToListen"
|
||||
class="filter-btn"
|
||||
>
|
||||
Want to Listen ({{ getCountByStatus(MusicStatus.wantToListen) }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading music...</div>
|
||||
} @else if (filteredMusic().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No music found with these filters.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="music-grid">
|
||||
@for (music of filteredMusic(); 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">
|
||||
} @else {
|
||||
<div class="music-cover placeholder">
|
||||
@switch (music.type) {
|
||||
@case (MusicType.album) { 💿 }
|
||||
@case (MusicType.single) { 🎵 }
|
||||
@case (MusicType.ep) { 🎶 }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="music-info">
|
||||
<h3>{{ music.title }}</h3>
|
||||
<p class="artist">{{ music.artist }}</p>
|
||||
|
||||
<div class="badges">
|
||||
<span class="type-badge type-{{ music.type }}">
|
||||
{{ getTypeLabel(music.type) }}
|
||||
</span>
|
||||
<span class="status status-{{ music.status }}">
|
||||
{{ getStatusLabel(music.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (music.rating) {
|
||||
<div class="rating">
|
||||
@for (star of [1,2,3,4,5]; track star) {
|
||||
<span [class.filled]="star <= music.rating">★</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (music.notes) {
|
||||
<p class="notes">{{ music.notes }}</p>
|
||||
}
|
||||
|
||||
@if (music.dateCompleted) {
|
||||
<p class="date-completed">
|
||||
Completed: {{ formatDate(music.dateCompleted) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (authService.isAdmin()) {
|
||||
<div class="actions">
|
||||
<button (click)="deleteMusic(music)" class="btn btn-danger btn-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--witch-moon);
|
||||
color: var(--witch-purple);
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group strong {
|
||||
min-width: 60px;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--witch-mauve);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.music-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.music-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.music-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.music-card.completed {
|
||||
opacity: 0.9;
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.music-cover {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.music-cover.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--witch-lavender);
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.music-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.music-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.artist {
|
||||
color: var(--witch-plum);
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-album {
|
||||
background: var(--witch-purple);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
.type-single {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
.type-ep {
|
||||
background: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-listening {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
.status-completed {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
.status-wantToListen {
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.rating {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.rating span {
|
||||
color: var(--witch-lavender);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.rating span.filled {
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-plum);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-completed {
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-plum);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--witch-plum);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MusicListComponent implements OnInit {
|
||||
musicService = inject(MusicService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
music = signal<Music[]>([]);
|
||||
loading = signal(true);
|
||||
showAddForm = signal(false);
|
||||
typeFilter = signal<'all' | MusicType>('all');
|
||||
statusFilter = signal<'all' | MusicStatus>('all');
|
||||
|
||||
// Expose enums to template
|
||||
MusicType = MusicType;
|
||||
MusicStatus = MusicStatus;
|
||||
|
||||
newMusic: Partial<CreateMusicDto> = {
|
||||
title: '',
|
||||
artist: '',
|
||||
type: MusicType.album,
|
||||
status: MusicStatus.wantToListen,
|
||||
rating: undefined,
|
||||
notes: ''
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.loadMusic();
|
||||
}
|
||||
|
||||
loadMusic() {
|
||||
this.loading.set(true);
|
||||
this.musicService.getAllMusic().subscribe({
|
||||
next: (music) => {
|
||||
this.music.set(music);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
filteredMusic() {
|
||||
let filtered = this.music();
|
||||
|
||||
const typeFilter = this.typeFilter();
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(music => music.type === typeFilter);
|
||||
}
|
||||
|
||||
const statusFilter = this.statusFilter();
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(music => music.status === statusFilter);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
getCountByType(type: MusicType): number {
|
||||
return this.music().filter(music => music.type === type).length;
|
||||
}
|
||||
|
||||
getCountByStatus(status: MusicStatus): number {
|
||||
return this.music().filter(music => music.status === status).length;
|
||||
}
|
||||
|
||||
setTypeFilter(filter: 'all' | MusicType) {
|
||||
this.typeFilter.set(filter);
|
||||
}
|
||||
|
||||
setStatusFilter(filter: 'all' | MusicStatus) {
|
||||
this.statusFilter.set(filter);
|
||||
}
|
||||
|
||||
getTypeLabel(type: MusicType): string {
|
||||
switch (type) {
|
||||
case MusicType.album: return 'Album';
|
||||
case MusicType.single: return 'Single';
|
||||
case MusicType.ep: return 'EP';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusLabel(status: MusicStatus): string {
|
||||
switch (status) {
|
||||
case MusicStatus.listening: return 'Currently Listening';
|
||||
case MusicStatus.completed: return 'Completed';
|
||||
case MusicStatus.wantToListen: return 'Want to Listen';
|
||||
}
|
||||
}
|
||||
|
||||
toggleAddForm() {
|
||||
this.showAddForm.update(v => !v);
|
||||
if (!this.showAddForm()) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.newMusic = {
|
||||
title: '',
|
||||
artist: '',
|
||||
type: MusicType.album,
|
||||
status: MusicStatus.wantToListen,
|
||||
rating: undefined,
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
|
||||
addMusic() {
|
||||
if (!this.newMusic.title || !this.newMusic.artist || !this.newMusic.type || !this.newMusic.status) return;
|
||||
|
||||
const musicToAdd: CreateMusicDto = {
|
||||
title: this.newMusic.title,
|
||||
artist: this.newMusic.artist,
|
||||
type: this.newMusic.type,
|
||||
status: this.newMusic.status,
|
||||
rating: this.newMusic.rating,
|
||||
notes: this.newMusic.notes
|
||||
};
|
||||
|
||||
this.musicService.createMusic(musicToAdd).subscribe(() => {
|
||||
this.loadMusic();
|
||||
this.toggleAddForm();
|
||||
});
|
||||
}
|
||||
|
||||
deleteMusic(music: Music) {
|
||||
if (confirm(`Are you sure you want to delete "${music.title}" by ${music.artist}?`)) {
|
||||
this.musicService.deleteMusic(music.id).subscribe(() => {
|
||||
this.loadMusic();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user