import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ImageRecord, ImageService } from '../services/image.service'; import { AuthService } from '../auth/auth.service'; const PLACEHOLDER_SVG = `data:image/svg+xml,🔗`; @Component({ selector: 'app-detail', standalone: true, imports: [CommonModule, FormsModule, RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `

Failed to load image. Please check your connection.

Back to library

Image not found

This image may have been deleted or the URL is incorrect.

Back to library

{{ image.filename }}

Tags

{{ tag }}

{{ tagError }}

Permanently delete this image?

`, styles: [` .detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; } /* Skeleton */ .image-skeleton { width: 100%; height: 400px; margin-bottom: 16px; } .chip-row-skeleton { display: flex; gap: 8px; margin-bottom: 10px; padding: 0 16px; } .chip-skeleton { width: 64px; height: 28px; border-radius: var(--radius-chip); } .chip-skeleton.short { width: 48px; } /* Network error card */ .fetch-error-card { max-width: 520px; margin: 80px auto; text-align: center; padding: 40px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); } .error-icon { display: block; font-size: 2rem; color: var(--danger); margin-bottom: 12px; } .error-actions { display: flex; justify-content: center; gap: 16px; margin-top: 20px; } .retry-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; } .retry-btn:hover { border-color: var(--border-focus); } .back-link { color: var(--accent); text-decoration: none; line-height: 2.2; } /* Not-found card */ .not-found-card { max-width: 480px; margin: 80px auto; text-align: center; padding: 48px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); } .not-found-icon { display: block; font-size: 2.5rem; color: var(--text-muted); margin-bottom: 16px; } .not-found-card h2 { margin-bottom: 8px; } .not-found-card p { color: var(--text-muted); margin-bottom: 24px; } /* Main detail */ .back-btn-inline { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; } .back-btn { display: inline-block; margin-top: 16px; padding: 10px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; text-decoration: none; } .back-btn:hover { border-color: var(--border-focus); } .full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: var(--radius); display: block; } .tags-section { margin-top: 24px; } .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } .chip { background: var(--surface-raised); padding: 4px 12px; border-radius: var(--radius-chip); display: flex; align-items: center; gap: 6px; } .chip button { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1rem; } .tag-error { color: var(--danger); font-size: 0.85rem; margin-top: 6px; border-left: 3px solid var(--danger); padding-left: 10px; } /* Owner actions panel */ .owner-actions { margin-top: 24px; padding: 20px; background: var(--surface); border-top: 1px solid var(--border); border-radius: var(--radius); display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .add-tag input { padding: 8px; background: var(--surface-raised); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); width: 200px; } .add-tag input:focus { outline: none; border-color: var(--border-focus); } .delete-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; } /* Delete dialog */ .dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; } .dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; } .dialog button { margin: 12px 8px 0; padding: 8px 20px; border: none; border-radius: var(--radius); cursor: pointer; } .dialog button:first-of-type { background: var(--danger); color: var(--danger-text); } .dialog button:last-of-type { background: var(--surface-raised); color: var(--text); } `], }) export class DetailComponent implements OnInit { image: ImageRecord | null = null; loading = true; error = false; newTagInput = ''; tagError = ''; showDeleteDialog = false; readonly skeletonChips = Array(4).fill(null); private currentId = ''; constructor( public imageService: ImageService, public auth: AuthService, private route: ActivatedRoute, public router: Router, private cdr: ChangeDetectorRef, ) {} ngOnInit(): void { const id = this.route.snapshot.paramMap.get('id'); if (!id) { this.loading = false; return; } this.currentId = id; this.fetchImage(id); } retry(): void { this.error = false; this.loading = true; this.cdr.markForCheck(); this.fetchImage(this.currentId); } private fetchImage(id: string): void { this.imageService.get(id).subscribe({ next: (img) => { this.image = img; this.loading = false; this.error = false; this.cdr.markForCheck(); }, error: (err) => { this.loading = false; if (err?.status === 404) { this.image = null; this.error = false; } else { this.error = true; } this.cdr.markForCheck(); }, }); } removeTag(tag: string): void { if (!this.image) return; const updated = this.image.tags.filter((t) => t !== tag); this.imageService.updateTags(this.image.id, updated).subscribe({ next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); }, error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); }, }); } addTag(tag: string): void { if (!this.image || !tag.trim()) return; const normalised = tag.trim().toLowerCase(); const updated = [...this.image.tags, normalised]; this.imageService.updateTags(this.image.id, updated).subscribe({ next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); }, error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); }, }); } onEnter(): void { this.addTag(this.newTagInput); } onBlur(): void { if (this.newTagInput.trim()) this.addTag(this.newTagInput); } confirmDelete(): void { if (!this.image) return; this.imageService.delete(this.image.id).subscribe({ next: () => this.router.navigate(['/']), error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); }, }); } cancelDelete(): void { this.showDeleteDialog = false; this.cdr.markForCheck(); } goBack(): void { this.router.navigate(['/']); } onImgError(event: Event): void { const img = event.target as HTMLImageElement; if (!img.src.startsWith('data:')) { img.src = PLACEHOLDER_SVG; } } }