feat: initial prototype works

I can log in and create a book! Woo!
This commit is contained in:
2026-02-04 12:17:05 -08:00
parent e167a17bd9
commit b6d66d34cb
44 changed files with 3695 additions and 493 deletions
@@ -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();
}
}