feat: add art component

This commit is contained in:
2026-02-04 13:40:52 -08:00
parent d338c8b52f
commit cbd6499079
12 changed files with 1162 additions and 0 deletions
+4
View File
@@ -17,6 +17,10 @@ export const appRoutes: Route[] = [
path: 'music',
loadComponent: () => import('./components/music/music-list.component').then(m => m.MusicListComponent)
},
{
path: 'art',
loadComponent: () => import('./components/art/art-gallery.component').then(m => m.ArtGalleryComponent)
},
{
path: '**',
redirectTo: ''
@@ -0,0 +1,824 @@
/**
* @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 { ArtService } from '../../services/art.service';
import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types';
@Component({
selector: 'app-art-gallery',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="container">
<div class="header-section">
<h2>Art Gallery</h2>
<p class="subtitle">Artwork of Naomi</p>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Art' }}
</button>
}
</div>
<div class="disclaimer">
<p>
<strong>A note on AI-generated art:</strong> Yes, we understand the negative impacts of AI art on working artists.
However, seeing ourselves represented gives us gender euphoria and dopamine hits in an otherwise shitty and depressing world.
If you want to yell at someone about AI art, go find someone who's actually profiting from their AI slop instead of bothering us. Thanks!
</p>
<p class="callout">
That said, if you'd like to draw Naomi and send it our way, we'd be absolutely delighted to display it here!
</p>
</div>
@if (showAddForm() && authService.isAdmin()) {
<form (ngSubmit)="addArt()" class="add-form">
<h3>Add New Artwork</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="newArt.title"
name="title"
required
placeholder="Artwork title"
>
</div>
<div class="form-group">
<label for="artist">Artist</label>
<input
type="text"
id="artist"
[(ngModel)]="newArt.artist"
name="artist"
required
placeholder="Who created this artwork"
>
</div>
<div class="form-group">
<label for="imageUrl">Image URL (CDN)</label>
<input
type="url"
id="imageUrl"
[(ngModel)]="newArt.imageUrl"
name="imageUrl"
required
placeholder="https://cdn.example.com/image.png"
>
</div>
<div class="form-group">
<label for="description">Description (used as alt text)</label>
<textarea
id="description"
[(ngModel)]="newArt.description"
name="description"
rows="2"
placeholder="Describe the artwork for accessibility..."
></textarea>
</div>
@if (newArt.imageUrl) {
<div class="image-preview">
<p>Preview:</p>
<img [src]="newArt.imageUrl" [alt]="newArt.description || 'Preview'" (error)="onImageError($event)">
</div>
}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Artwork</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (editingArt() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Artwork</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editArt.title"
name="title"
required
placeholder="Artwork title"
>
</div>
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editArt.artist"
name="artist"
required
placeholder="Who created this artwork"
>
</div>
<div class="form-group">
<label for="edit-imageUrl">Image URL (CDN)</label>
<input
type="url"
id="edit-imageUrl"
[(ngModel)]="editArt.imageUrl"
name="imageUrl"
required
placeholder="https://cdn.example.com/image.png"
>
</div>
<div class="form-group">
<label for="edit-description">Description (used as alt text)</label>
<textarea
id="edit-description"
[(ngModel)]="editArt.description"
name="description"
rows="2"
placeholder="Describe the artwork for accessibility..."
></textarea>
</div>
@if (editArt.imageUrl) {
<div class="image-preview">
<p>Preview:</p>
<img [src]="editArt.imageUrl" [alt]="editArt.description || 'Preview'" (error)="onImageError($event)">
</div>
}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (loading()) {
<div class="loading">Loading gallery...</div>
} @else if (artPieces().length === 0) {
<div class="empty-state">
<p>No artwork in the gallery yet.</p>
</div>
} @else {
<div class="gallery-grid">
@for (art of artPieces(); track art.id) {
<div class="art-card">
<div class="art-image-container">
<img
[src]="art.imageUrl"
[alt]="art.description || art.title"
class="art-image"
(click)="openLightbox(art)"
>
</div>
<div class="art-info">
<h3>{{ art.title }}</h3>
<p class="artist">by {{ art.artist }}</p>
<p class="date-added">Added: {{ formatDate(art.dateAdded) }}</p>
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(art)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteArt(art)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(art.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[art.id] ? 'Hide' : 'Show' }} Comments{{ comments()[art.id] ? ' (' + getCommentCount(art.id) + ')' : '' }}
</button>
@if (expandedComments()[art.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
<form (ngSubmit)="addComment(art.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[art.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
@if (commentsLoading()[art.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[art.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (authService.isAdmin()) {
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
<div class="comment-content" [innerHTML]="comment.content"></div>
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
</div>
}
@if (lightboxArt()) {
<div class="lightbox" (click)="closeLightbox()">
<div class="lightbox-content" (click)="$event.stopPropagation()">
<button class="lightbox-close" (click)="closeLightbox()">&times;</button>
<img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title">
<div class="lightbox-info">
<h3>{{ lightboxArt()!.title }}</h3>
<p>by {{ lightboxArt()!.artist }}</p>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.header-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
gap: 0.5rem;
}
.header-section h2 {
margin: 0;
}
.subtitle {
color: var(--witch-mauve);
margin: 0;
font-style: italic;
}
.disclaimer {
background: linear-gradient(135deg, var(--witch-lavender) 0%, var(--witch-mauve) 100%);
border: 2px solid var(--witch-plum);
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 2rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.disclaimer p {
margin: 0;
color: var(--witch-purple);
font-size: 0.95rem;
line-height: 1.6;
text-align: center;
}
.disclaimer p.callout {
margin-top: 0.75rem;
font-style: italic;
color: var(--witch-plum);
}
.disclaimer strong {
color: var(--witch-plum);
}
.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);
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--witch-plum);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
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 textarea:focus {
outline: none;
border-color: var(--witch-rose);
}
.image-preview {
margin: 1rem 0;
text-align: center;
}
.image-preview img {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
border: 2px solid var(--witch-lavender);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.loading, .empty-state {
text-align: center;
padding: 3rem;
color: var(--witch-mauve);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 2rem;
}
.art-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px var(--witch-shadow);
border: 2px solid var(--witch-lavender);
transition: transform 0.3s, box-shadow 0.3s;
}
.art-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px var(--witch-shadow);
}
.art-image-container {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
}
.art-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.art-image:hover {
transform: scale(1.05);
}
.art-info {
padding: 1rem;
}
.art-info h3 {
margin: 0 0 0.5rem 0;
color: var(--witch-plum);
}
.artist {
color: var(--witch-mauve);
font-style: italic;
margin: 0 0 0.5rem 0;
}
.date-added {
font-size: 0.85rem;
color: var(--witch-mauve);
margin: 0 0 1rem 0;
}
.actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-2px);
}
.btn-primary {
background: var(--witch-rose);
color: var(--witch-moon);
}
.btn-secondary {
background: var(--witch-mauve);
color: var(--witch-purple);
}
.btn-danger {
background: var(--witch-plum);
color: var(--witch-moon);
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.btn-xs {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
}
/* Lightbox */
.lightbox {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
text-align: center;
}
.lightbox-content img {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
}
.lightbox-info {
color: white;
margin-top: 1rem;
}
.lightbox-info h3 {
margin: 0;
color: white;
}
.lightbox-info p {
margin: 0.5rem 0 0 0;
opacity: 0.8;
}
/* Comments */
.comments-section {
margin-top: 1rem;
border-top: 1px solid var(--witch-lavender);
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--witch-moon);
color: var(--witch-purple);
resize: vertical;
margin-bottom: 0.5rem;
}
.comment-form textarea:focus {
outline: none;
border-color: var(--witch-rose);
}
.comment {
background: var(--witch-moon);
border: 1px solid var(--witch-lavender);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: var(--witch-plum);
}
.comment-date {
font-size: 0.75rem;
color: var(--witch-mauve);
}
.comment-content {
font-size: 0.9rem;
color: var(--witch-purple);
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: var(--witch-mauve);
font-size: 0.9rem;
}
`]
})
export class ArtGalleryComponent implements OnInit {
artService = inject(ArtService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
artPieces = signal<Art[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingArt = signal<Art | null>(null);
lightboxArt = signal<Art | null>(null);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
newArt: Partial<CreateArtDto> = {
title: '',
artist: '',
imageUrl: '',
description: ''
};
editArt: Partial<UpdateArtDto> = {};
ngOnInit() {
this.loadArt();
}
loadArt() {
this.loading.set(true);
this.artService.getAllArt().subscribe({
next: (art) => {
this.artPieces.set(art);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newArt = {
title: '',
artist: '',
imageUrl: '',
description: ''
};
}
addArt() {
if (!this.newArt.title || !this.newArt.artist || !this.newArt.imageUrl) return;
const artToAdd: CreateArtDto = {
title: this.newArt.title,
artist: this.newArt.artist,
imageUrl: this.newArt.imageUrl,
description: this.newArt.description
};
this.artService.createArt(artToAdd).subscribe(() => {
this.loadArt();
this.toggleAddForm();
});
}
deleteArt(art: Art) {
if (confirm(`Are you sure you want to delete "${art.title}"?`)) {
this.artService.deleteArt(art.id).subscribe(() => {
this.loadArt();
});
}
}
startEdit(art: Art) {
this.editingArt.set(art);
this.editArt = {
title: art.title,
artist: art.artist,
imageUrl: art.imageUrl,
description: art.description
};
this.showAddForm.set(false);
}
cancelEdit() {
this.editingArt.set(null);
this.editArt = {};
}
saveEdit() {
const art = this.editingArt();
if (!art || !this.editArt.title || !this.editArt.artist || !this.editArt.imageUrl) return;
this.artService.updateArt(art.id, this.editArt).subscribe(() => {
this.loadArt();
this.cancelEdit();
});
}
openLightbox(art: Art) {
this.lightboxArt.set(art);
}
closeLightbox() {
this.lightboxArt.set(null);
}
onImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.style.display = 'none';
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
// Comments methods
toggleComments(artId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[artId];
this.expandedComments.set({
...expanded,
[artId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[artId]) {
this.loadComments(artId);
}
}
loadComments(artId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[artId]: true
});
this.commentsService.getCommentsForArt(artId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[artId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[artId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[artId]: false
});
}
});
}
getCommentCount(artId: string): number {
return this.comments()[artId]?.length || 0;
}
addComment(artId: string) {
const content = this.newCommentContent[artId];
if (!content?.trim()) return;
this.commentsService.addCommentToArt(artId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[artId]: [comment, ...(this.comments()[artId] || [])]
});
this.newCommentContent[artId] = '';
}
});
}
deleteComment(artId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromArt(artId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[artId]: (this.comments()[artId] || []).filter(c => c.id !== commentId)
});
}
});
}
}
@@ -24,6 +24,7 @@ import { AuthService } from '../../services/auth.service';
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
</ul>
<div class="auth-section">
@@ -0,0 +1,37 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
@Injectable({
providedIn: 'root'
})
export class ArtService {
constructor(private api: ApiService) {}
getAllArt(): Observable<Art[]> {
return this.api.get<Art[]>('/art');
}
getArtById(id: string): Observable<Art | null> {
return this.api.get<Art | null>(`/art/${id}`);
}
createArt(art: CreateArtDto): Observable<Art> {
return this.api.post<Art>('/art', art);
}
updateArt(id: string, art: UpdateArtDto): Observable<Art> {
return this.api.put<Art>(`/art/${id}`, art);
}
deleteArt(id: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/art/${id}`);
}
}
@@ -50,4 +50,16 @@ export class CommentsService {
deleteCommentFromMusic(musicId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/music/${musicId}/comments/${commentId}`);
}
getCommentsForArt(artId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/art/${artId}/comments`);
}
addCommentToArt(artId: string, comment: CreateCommentDto): Observable<Comment> {
return this.api.post<Comment>(`/art/${artId}/comments`, comment);
}
deleteCommentFromArt(artId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/art/${artId}/comments/${commentId}`);
}
}