Files
library/apps/frontend/src/app/components/art/art-gallery.component.ts
T
naomi 86404497f0
Node.js CI / CI (push) Successful in 1m21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s
feat: implement user profiles with achievements and primary badge system (#58)
## Summary

This PR implements comprehensive user profile enhancements including:
- User profile pages showing stats, badges, social links, and bio
- Achievement system with 62 achievements across 5 categories
- Primary badge selection allowing users to display their preferred badge
- Admin profile editing capabilities

## Changes

### User Profiles (#45)
- **Frontend**: User profile pages with stats display
  - Profile cards showing avatar, display name, username, and bio
  - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord)
  - Stats display (suggestions, accepted suggestions, likes, comments)
  - Recent achievements section
  - Badge display
  - Report button for other users' profiles
- **Backend**: Profile API endpoints
  - Get user profile by username or ID
  - Profile includes stats, badges, and achievement points

### Achievement System (#48)
- **Database**: UserAchievement model for tracking progress
- **62 Total Achievements** across 5 categories:
  - **Suggestions (15)**: First suggestion through ultimate curator
  - **Likes (12)**: First like through legendary fan
  - **Comments (12)**: First comment through review legend
  - **Engagement (15)**: Login streaks and activity milestones
  - **Reports (8)**: Valid reports and accuracy tracking
- **Backend**: AchievementService with real-time checking
  - Integrated into all user interaction points
  - API endpoints for achievement data
  - Progress tracking to avoid recalculation
- **Frontend**: Achievements page and profile integration
  - Full achievements page with category filtering
  - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond)
  - Progress indicators for in-progress achievements
  - Recent achievements on profile pages

### Primary Badge System (#49)
- **Database**: Add primaryBadge field to User model
- **Backend**: Update profile endpoints to include primary badge
- **Frontend**: Primary badge selection in settings
  - Only shows badges the user has earned
  - Displayed on profile page
  - Displayed in comments (next to username)
  - Falls back to no badge if selection is invalid
- **Admin Features**: Admin can edit any user's primary badge

### Admin Enhancements
- Comprehensive profile editing modal for admins
  - Edit display name, bio, slug, social links
  - Set primary badge for users
  - Visual feedback for save/error states
- Admin action buttons in report review modals
  - Ban user, delete comment, edit profile
  - Integrated with report workflow

### Quality Improvements
- Improved dropdown option contrast for readability
- Hide all badges when no primary badge is selected
- "View All" achievements link only shown on own profile
- Improved achievement text readability

## Testing

-  User profiles display correctly with stats and badges
-  Achievement checking works for all interaction types
-  Primary badge selection persists and displays correctly
-  Admin profile editing saves successfully
-  Report workflow integrated with admin actions
-  Achievements page shows all 62 achievements with filtering
-  Text readability improved across components

Closes #45
Closes #48
Closes #49

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #58
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-19 22:21:17 -08:00

1592 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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: `
<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">
<div class="art-image-container">
<img
[src]="art.imageUrl"
[alt]="art.description || art.title"
class="art-image"
(click)="openLightbox(art)"
(keyup.enter)="openLightbox(art)"
(keyup.space)="openLightbox(art)"
tabindex="0"
role="button"
>
</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>
<app-like-button
entityType="art"
[entityId]="art.id"
></app-like-button>
@if (art.tags && art.tags.length > 0) {
<div class="tags-display">
@for (tag of art.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (art.links && art.links.length > 0) {
<div class="links-display">
@for (link of art.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@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()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<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>
}
}
<app-comment-display
[comments]="getCommentsSignal(art.id)"
(edit)="handleCommentEdit(art.id, $event)"
(delete)="deleteComment(art.id, $event)"
/>
</div>
}
</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()">&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;
}
.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<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 = '';
}
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] || []);
}
}