generated from nhcarrigan/template
d74a342f63
Added a beautiful default cover image featuring Naomi reading in her cozy library, which will be displayed whenever a media item doesn't have a cover image provided. Changes: - Added default-cover.jpg to frontend public/assets directory - Updated all list components (games, books, music, shows, manga, art) to always display an image using the default as fallback - Updated all detail components to always show cover section with default fallback - Removed conditional rendering of cover images - now always visible - Properly handles different property names (coverImage, coverArt, imageUrl) Benefits: - Consistent visual appearance across all media types - No more empty spaces where cover images would be - Better UX with uniform card layouts - Beautiful placeholder that matches the library theme Co-Authored-By: Naomi Carrigan <commits@nhcarrigan.com>
1359 lines
36 KiB
TypeScript
1359 lines
36 KiB
TypeScript
/**
|
||
* @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 { RouterModule } from '@angular/router';
|
||
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 { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||
|
||
@Component({
|
||
selector: 'app-art-gallery',
|
||
standalone: true,
|
||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||
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>
|
||
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
|
||
<button (click)="toggleSuggestForm()" class="btn btn-primary">
|
||
{{ showSuggestForm() ? 'Cancel' : 'Suggest 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-group">
|
||
<div class="tags-input-container" aria-label="Tags">
|
||
@for (tag of newArt.tags; track tag; let i = $index) {
|
||
<span class="tag">
|
||
{{ tag }}
|
||
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
|
||
</span>
|
||
}
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="newTagInput"
|
||
name="newTagInput"
|
||
placeholder="Add a tag and press Enter"
|
||
(keydown.enter)="addTag('new'); $event.preventDefault()"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" aria-label="External Links">
|
||
<div class="links-list">
|
||
@for (link of newArt.links; track link.url; let i = $index) {
|
||
<div class="link-item">
|
||
<span>{{ link.title }}: {{ link.url }}</span>
|
||
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
<div class="link-add-form">
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="newLinkTitle"
|
||
name="newLinkTitle"
|
||
placeholder="Link title (e.g., DeviantArt)"
|
||
>
|
||
<input
|
||
type="url"
|
||
[(ngModel)]="newLinkUrl"
|
||
name="newLinkUrl"
|
||
placeholder="https://..."
|
||
>
|
||
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
|
||
</div>
|
||
</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 (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {
|
||
<form (ngSubmit)="submitSuggestion()" class="add-form suggest-form">
|
||
<h3>Suggest Art</h3>
|
||
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the gallery!</p>
|
||
<div class="form-group">
|
||
<label for="suggest-title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="suggest-title"
|
||
[(ngModel)]="suggestedArt.title"
|
||
name="title"
|
||
required
|
||
placeholder="Artwork title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-artist">Artist</label>
|
||
<input
|
||
type="text"
|
||
id="suggest-artist"
|
||
[(ngModel)]="suggestedArt.artist"
|
||
name="artist"
|
||
required
|
||
placeholder="Who created this artwork"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-imageUrl">Image URL</label>
|
||
<input
|
||
type="url"
|
||
id="suggest-imageUrl"
|
||
[(ngModel)]="suggestedArt.imageUrl"
|
||
name="imageUrl"
|
||
required
|
||
placeholder="https://example.com/image.png"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-description">Description (optional, used as alt text)</label>
|
||
<textarea
|
||
id="suggest-description"
|
||
[(ngModel)]="suggestedArt.description"
|
||
name="description"
|
||
rows="2"
|
||
placeholder="Describe the artwork for accessibility..."
|
||
></textarea>
|
||
</div>
|
||
|
||
@if (suggestedArt.imageUrl) {
|
||
<div class="image-preview">
|
||
<p>Preview:</p>
|
||
<img [src]="suggestedArt.imageUrl" [alt]="suggestedArt.description || 'Preview'" (error)="onImageError($event)">
|
||
</div>
|
||
}
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary">Submit Suggestion</button>
|
||
<button type="button" (click)="toggleSuggestForm()" 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-group">
|
||
<div class="tags-input-container" aria-label="Tags">
|
||
@for (tag of editArt.tags; track tag; let i = $index) {
|
||
<span class="tag">
|
||
{{ tag }}
|
||
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
|
||
</span>
|
||
}
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="editTagInput"
|
||
name="editTagInput"
|
||
placeholder="Add a tag and press Enter"
|
||
(keydown.enter)="addTag('edit'); $event.preventDefault()"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" aria-label="External Links">
|
||
<div class="links-list">
|
||
@for (link of editArt.links; track link.url; let i = $index) {
|
||
<div class="link-item">
|
||
<span>{{ link.title }}: {{ link.url }}</span>
|
||
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
<div class="link-add-form">
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="editLinkTitle"
|
||
name="editLinkTitle"
|
||
placeholder="Link title (e.g., DeviantArt)"
|
||
>
|
||
<input
|
||
type="url"
|
||
[(ngModel)]="editLinkUrl"
|
||
name="editLinkUrl"
|
||
placeholder="https://..."
|
||
>
|
||
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
|
||
</div>
|
||
</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>
|
||
}
|
||
|
||
<div class="search-section">
|
||
<input
|
||
type="text"
|
||
[value]="searchQuery()"
|
||
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
|
||
name="search"
|
||
placeholder="Search by title, artist, or description..."
|
||
class="search-input"
|
||
>
|
||
<button (click)="toggleFilters()" class="btn btn-secondary btn-sm">
|
||
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
|
||
@if (selectedTags().length > 0) {
|
||
({{ selectedTags().length }})
|
||
}
|
||
</button>
|
||
@if (searchQuery() || selectedTags().length > 0) {
|
||
<button (click)="clearFilters()" class="btn btn-secondary btn-sm">
|
||
Clear All Filters
|
||
</button>
|
||
}
|
||
</div>
|
||
|
||
@if (showFilters()) {
|
||
<div class="advanced-filters">
|
||
<div class="filter-group">
|
||
<h4>Filter by Tags</h4>
|
||
<div class="tags-filter">
|
||
@for (tag of allTags(); track tag) {
|
||
<label class="tag-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
[checked]="selectedTags().includes(tag)"
|
||
(change)="toggleTag(tag)"
|
||
>
|
||
<span>{{ tag }}</span>
|
||
</label>
|
||
}
|
||
@empty {
|
||
<p class="no-tags">No tags available</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@if (loading()) {
|
||
<div class="loading">Loading gallery...</div>
|
||
} @else if (filteredArtPieces().length === 0) {
|
||
<div class="empty-state">
|
||
<p>No artwork found with these filters.</p>
|
||
</div>
|
||
} @else {
|
||
<app-pagination
|
||
[currentPage]="currentPage()"
|
||
[pageSize]="pageSize()"
|
||
[totalItems]="totalArtPieces()"
|
||
(pageChange)="onPageChange($event)"
|
||
(pageSizeChange)="onPageSizeChange($event)"
|
||
></app-pagination>
|
||
|
||
<div class="gallery-grid">
|
||
@for (art of paginatedArtPieces(); track art.id) {
|
||
<div class="art-card">
|
||
<a [routerLink]="['/art', art.id]" class="card-link">
|
||
<div class="art-image-container">
|
||
<img
|
||
[src]="art.imageUrl || '/assets/default-cover.jpg'"
|
||
[alt]="art.description || art.title"
|
||
class="art-image"
|
||
>
|
||
</div>
|
||
|
||
<div class="art-info">
|
||
<h3>{{ art.title }}</h3>
|
||
<p class="artist">by {{ art.artist }}</p>
|
||
</div>
|
||
</a>
|
||
|
||
<div class="card-actions">
|
||
<app-like-button
|
||
entityType="art"
|
||
[entityId]="art.id"
|
||
></app-like-button>
|
||
|
||
@if (authService.isAdmin()) {
|
||
<div class="admin-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>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<app-pagination
|
||
[currentPage]="currentPage()"
|
||
[pageSize]="pageSize()"
|
||
[totalItems]="totalArtPieces()"
|
||
(pageChange)="onPageChange($event)"
|
||
(pageSizeChange)="onPageSizeChange($event)"
|
||
></app-pagination>
|
||
}
|
||
|
||
@if (lightboxArt()) {
|
||
<div class="lightbox" (click)="closeLightbox()" (keyup.escape)="closeLightbox()" tabindex="0" role="button">
|
||
<div class="lightbox-content" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||
<button class="lightbox-close" (click)="closeLightbox()">×</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;
|
||
}
|
||
|
||
.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;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.art-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 24px var(--witch-shadow);
|
||
}
|
||
|
||
.card-link {
|
||
text-decoration: none;
|
||
color: inherit;
|
||
display: block;
|
||
flex: 1;
|
||
}
|
||
|
||
.art-image-container {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.art-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
.card-link:hover .art-image {
|
||
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;
|
||
}
|
||
|
||
.card-actions {
|
||
padding: 0 1rem 1rem 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.admin-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
`]
|
||
})
|
||
export class ArtGalleryComponent implements OnInit {
|
||
artService = inject(ArtService);
|
||
authService = inject(AuthService);
|
||
commentsService = inject(CommentsService);
|
||
sanitizeService = inject(SanitizeService);
|
||
suggestionService = inject(SuggestionService);
|
||
|
||
artPieces = signal<Art[]>([]);
|
||
loading = signal(true);
|
||
showAddForm = signal(false);
|
||
editingArt = signal<Art | null>(null);
|
||
lightboxArt = signal<Art | null>(null);
|
||
|
||
// Pagination state
|
||
currentPage = signal(1);
|
||
pageSize = signal(25);
|
||
|
||
// Search and filter state
|
||
searchQuery = signal('');
|
||
selectedTags = signal<string[]>([]);
|
||
showFilters = signal(false);
|
||
|
||
// Comments state
|
||
comments = signal<Record<string, Comment[]>>({});
|
||
commentsLoading = signal<Record<string, boolean>>({});
|
||
expandedComments = signal<Record<string, boolean>>({});
|
||
newCommentContent: Record<string, string> = {};
|
||
editingCommentId = signal<string | null>(null);
|
||
editCommentContent = '';
|
||
|
||
// Suggestion state
|
||
showSuggestForm = signal(false);
|
||
suggestedArt: { title: string; artist: string; imageUrl: string; description?: string } = {
|
||
title: '',
|
||
artist: '',
|
||
imageUrl: '',
|
||
description: ''
|
||
};
|
||
|
||
newArt: Partial<CreateArtDto> = {
|
||
title: '',
|
||
artist: '',
|
||
imageUrl: '',
|
||
description: '',
|
||
tags: [],
|
||
links: []
|
||
};
|
||
|
||
editArt: Partial<UpdateArtDto> = {};
|
||
|
||
// Tags and links input state
|
||
newTagInput = '';
|
||
editTagInput = '';
|
||
newLinkTitle = '';
|
||
newLinkUrl = '';
|
||
editLinkTitle = '';
|
||
editLinkUrl = '';
|
||
|
||
// Computed properties
|
||
allTags = computed(() => {
|
||
const tagsSet = new Set<string>();
|
||
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 = '';
|
||
|
||
// Scroll to top so the form is visible
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
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] || []);
|
||
}
|
||
}
|