/** * @copyright 2026 NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ 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'; 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 { LikeButtonComponent } from '../shared/like-button.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-art-gallery', standalone: true, imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `

Art Gallery

Artwork of Naomi

@if (authService.isAdmin()) { } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { }

A note on AI-generated art: 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!

That said, if you'd like to draw Naomi and send it our way, we'd be absolutely delighted to display it here!

@if (showAddForm() && authService.isAdmin()) {

Add New Artwork

@if (newArt.imageUrl) {

Preview:

}
@for (tag of newArt.tags; track tag; let i = $index) { {{ tag }} }
} @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {

Suggest Art

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the gallery!

@if (suggestedArt.imageUrl) {

Preview:

}
} @if (editingArt() && authService.isAdmin()) {

Edit Artwork

@if (editArt.imageUrl) {

Preview:

}
@for (tag of editArt.tags; track tag; let i = $index) { {{ tag }} }
}
@if (searchQuery() || selectedTags().length > 0) { }
@if (showFilters()) {

Filter by Tags

@for (tag of allTags(); track tag) { } @empty {

No tags available

}
} @if (loading()) {
Loading gallery...
} @else if (filteredArtPieces().length === 0) {

No artwork found with these filters.

} @else { } @if (lightboxArt()) { }
`, 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; } .search-section { display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; } .search-input { flex: 1; min-width: 250px; padding: 0.5rem 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; } .search-input:focus { outline: none; border-color: var(--witch-rose); } .advanced-filters { background: #f8f9fa; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; } .filter-group h4 { margin: 0 0 0.75rem 0; color: #374151; font-size: 0.95rem; font-weight: 600; } .tags-filter { display: flex; flex-wrap: wrap; gap: 0.75rem; } .tag-checkbox { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; padding: 0.25rem 0.75rem; border: 1px solid #e5e7eb; border-radius: 20px; background: white; transition: all 0.2s; } .tag-checkbox:hover { border-color: var(--witch-rose); background: #fff5f8; } .tag-checkbox input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; } .tag-checkbox span { font-size: 0.875rem; color: #374151; } .no-tags { color: #6b7280; font-style: italic; font-size: 0.875rem; } .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; } .suggest-form { border: 2px solid #fdcb6e; background: #fffdf5; } .suggest-note { color: #666; font-size: 0.9rem; margin-bottom: 1rem; font-style: italic; } .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: #fdcb6e; color: #333; } .btn-secondary { background: #6b7280; color: white; } .btn-danger { background: #ef4444; color: white; } .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); } .discord-badge { background: #5865f2; color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 500; } .vip-badge { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #1a1a1a; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; } .mod-badge { background: linear-gradient(135deg, #00b894, #00cec9); color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; } .staff-badge { background: linear-gradient(135deg, #e84393, #fd79a8); color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; } .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; } .banned-notice { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 0.75rem 1rem; border-radius: 4px; text-align: center; margin-bottom: 1rem; font-size: 0.9rem; } .comment-edit-form { margin-top: 0.5rem; } .comment-edit-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; } .comment-edit-form textarea:focus { outline: none; border-color: var(--witch-rose); } .comment-edit-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .tags-input-container { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; border: 2px solid var(--witch-lavender); border-radius: 4px; background: var(--witch-moon); } .tags-input-container input { flex: 1; min-width: 150px; border: none; padding: 0.25rem; font-size: 0.9rem; background: transparent; } .tags-input-container input:focus { outline: none; } .tag { display: inline-flex; align-items: center; gap: 0.25rem; background: #fdcb6e; color: #333; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; } .tag-remove { background: none; border: none; color: #333; cursor: pointer; padding: 0; font-size: 1rem; line-height: 1; } .tag-remove:hover { opacity: 0.8; } .tags-display { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; } .tag-chip { background: rgba(253, 203, 110, 0.2); color: #b38f00; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } .links-list { margin-bottom: 0.5rem; } .link-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; background: var(--witch-moon); border-radius: 4px; margin-bottom: 0.25rem; } .link-add-form { display: flex; gap: 0.5rem; flex-wrap: wrap; } .link-add-form input { flex: 1; min-width: 120px; } .links-display { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; } .external-link { color: #b38f00; text-decoration: none; font-size: 0.85rem; padding: 0.2rem 0.5rem; background: rgba(253, 203, 110, 0.1); border-radius: 4px; transition: background 0.2s; } .external-link:hover { background: rgba(253, 203, 110, 0.2); text-decoration: underline; } `] }) export class ArtGalleryComponent implements OnInit { artService = inject(ArtService); authService = inject(AuthService); commentsService = inject(CommentsService); sanitizeService = inject(SanitizeService); suggestionService = inject(SuggestionService); artPieces = signal([]); loading = signal(true); showAddForm = signal(false); editingArt = signal(null); lightboxArt = signal(null); // Pagination state currentPage = signal(1); pageSize = signal(25); // Search and filter state searchQuery = signal(''); selectedTags = signal([]); showFilters = signal(false); // Comments state comments = signal>({}); commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; editingCommentId = signal(null); editCommentContent = ''; // Suggestion state showSuggestForm = signal(false); suggestedArt: { title: string; artist: string; imageUrl: string; description?: string } = { title: '', artist: '', imageUrl: '', description: '' }; newArt: Partial = { title: '', artist: '', imageUrl: '', description: '', tags: [], links: [] }; editArt: Partial = {}; // Tags and links input state newTagInput = ''; editTagInput = ''; newLinkTitle = ''; newLinkUrl = ''; editLinkTitle = ''; editLinkUrl = ''; // Computed properties allTags = computed(() => { const tagsSet = new Set(); this.artPieces().forEach(art => { art.tags?.forEach(tag => tagsSet.add(tag)); }); return Array.from(tagsSet).sort(); }); filteredArtPieces = computed(() => { let artPieces = this.artPieces(); // Apply search filter const searchQuery = this.searchQuery().toLowerCase().trim(); if (searchQuery) { artPieces = artPieces.filter(art => art.title.toLowerCase().includes(searchQuery) || art.artist.toLowerCase().includes(searchQuery) || art.description?.toLowerCase().includes(searchQuery) ); } // Apply tag filter const selectedTags = this.selectedTags(); if (selectedTags.length > 0) { artPieces = artPieces.filter(art => selectedTags.every(tag => art.tags?.includes(tag)) ); } return artPieces; }); paginatedArtPieces = computed(() => { const artPieces = this.filteredArtPieces(); const start = (this.currentPage() - 1) * this.pageSize(); const end = start + this.pageSize(); return artPieces.slice(start, end); }); totalArtPieces = computed(() => this.filteredArtPieces().length); 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: '', tags: [], links: [] }; this.newTagInput = ''; this.newLinkTitle = ''; this.newLinkUrl = ''; } addTag(target: 'new' | 'edit') { const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim(); if (!input) return; if (target === 'new') { this.newArt.tags = [...(this.newArt.tags || []), input]; this.newTagInput = ''; } else { this.editArt.tags = [...(this.editArt.tags || []), input]; this.editTagInput = ''; } } removeTag(index: number, target: 'new' | 'edit') { if (target === 'new') { this.newArt.tags = (this.newArt.tags || []).filter((_, i) => i !== index); } else { this.editArt.tags = (this.editArt.tags || []).filter((_, i) => i !== index); } } addLink(target: 'new' | 'edit') { const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim(); const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim(); if (!title || !url) return; if (target === 'new') { this.newArt.links = [...(this.newArt.links || []), { title, url }]; this.newLinkTitle = ''; this.newLinkUrl = ''; } else { this.editArt.links = [...(this.editArt.links || []), { title, url }]; this.editLinkTitle = ''; this.editLinkUrl = ''; } } removeLink(index: number, target: 'new' | 'edit') { if (target === 'new') { this.newArt.links = (this.newArt.links || []).filter((_, i) => i !== index); } else { this.editArt.links = (this.editArt.links || []).filter((_, i) => i !== index); } } 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, tags: this.newArt.tags || [], links: this.newArt.links || [] }; 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, tags: [...(art.tags || [])], links: [...(art.links || [])] }; this.showAddForm.set(false); this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; } cancelEdit() { this.editingArt.set(null); this.editArt = {}; this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; } 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) }); } }); } canEditComment(comment: Comment): boolean { const user = this.authService.user(); if (!user) return false; return comment.userId === user.id || this.authService.isAdmin(); } canDeleteComment(comment: Comment): boolean { const user = this.authService.user(); if (!user) return false; return comment.userId === user.id || this.authService.isAdmin(); } startEditComment(artId: string, comment: Comment) { this.editingCommentId.set(comment.id); this.editCommentContent = comment.rawContent ?? comment.content; } cancelCommentEdit() { this.editingCommentId.set(null); this.editCommentContent = ''; } saveCommentEdit(artId: string, commentId: string) { if (!this.editCommentContent.trim()) return; this.commentsService.updateCommentOnArt(artId, commentId, this.editCommentContent).subscribe({ next: (updatedComment) => { this.comments.set({ ...this.comments(), [artId]: (this.comments()[artId] || []).map(c => c.id === commentId ? updatedComment : c ) }); this.cancelCommentEdit(); } }); } // Suggestion methods toggleSuggestForm() { this.showSuggestForm.update(v => !v); if (!this.showSuggestForm()) { this.resetSuggestForm(); } } resetSuggestForm() { this.suggestedArt = { title: '', artist: '', imageUrl: '', description: '' }; } async submitSuggestion() { if (!this.suggestedArt.title || !this.suggestedArt.artist || !this.suggestedArt.imageUrl) return; try { await this.suggestionService.createSuggestion({ entityType: SuggestionEntity.art, title: this.suggestedArt.title, artist: this.suggestedArt.artist, imageUrl: this.suggestedArt.imageUrl, description: this.suggestedArt.description }); alert('Thank you for your suggestion! It will be reviewed soon.'); this.toggleSuggestForm(); } catch { 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); } toggleTag(tag: string) { const current = this.selectedTags(); if (current.includes(tag)) { this.selectedTags.set(current.filter(t => t !== tag)); } else { this.selectedTags.set([...current, tag]); } this.currentPage.set(1); // Reset to first page when tags change } clearFilters() { this.searchQuery.set(''); this.selectedTags.set([]); this.currentPage.set(1); } toggleFilters() { this.showFilters.update(v => !v); } handleCommentEdit(artId: string, event: { commentId: string; content: string }) { this.commentsService.updateCommentOnArt(artId, event.commentId, event.content).subscribe({ next: (updatedComment) => { this.comments.set({ ...this.comments(), [artId]: (this.comments()[artId] || []).map(c => c.id === event.commentId ? updatedComment : c ) }); } }); } getCommentsSignal(artId: string) { return signal(this.comments()[artId] || []); } }