API responses now include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these point to the CDN domain; when unset, they fall back to the existing API proxy paths so local dev requires no additional setup. UI updated to use response URL fields directly instead of constructing proxy URLs client-side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
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,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">🔗</text></svg>`;
|
||
|
||
@Component({
|
||
selector: 'app-detail',
|
||
standalone: true,
|
||
imports: [CommonModule, FormsModule, RouterLink],
|
||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
template: `
|
||
<!-- Loading skeleton -->
|
||
<div class="detail-page" *ngIf="loading">
|
||
<div class="skeleton image-skeleton"></div>
|
||
<div class="chip-row-skeleton">
|
||
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton"></div>
|
||
</div>
|
||
<div class="chip-row-skeleton">
|
||
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton short"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Network error state -->
|
||
<div class="fetch-error-card" *ngIf="error && !loading">
|
||
<span class="error-icon">⚠</span>
|
||
<p>Failed to load image. Please check your connection.</p>
|
||
<div class="error-actions">
|
||
<button class="retry-btn" (click)="retry()">Retry</button>
|
||
<a routerLink="/" class="back-link">Back to library</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Not-found state -->
|
||
<div class="not-found-card" *ngIf="!image && !loading && !error">
|
||
<span class="not-found-icon">✦</span>
|
||
<h2>Image not found</h2>
|
||
<p>This image may have been deleted or the URL is incorrect.</p>
|
||
<a routerLink="/" class="back-btn">Back to library</a>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<div class="detail-page" *ngIf="image && !loading">
|
||
<button class="back-btn-inline" (click)="goBack()">← Back</button>
|
||
<h2>{{ image.filename }}</h2>
|
||
|
||
<img
|
||
class="full-image"
|
||
[src]="image.file_url"
|
||
[alt]="image.filename"
|
||
(error)="onImgError($event)"
|
||
/>
|
||
|
||
<section class="tags-section">
|
||
<h3>Tags</h3>
|
||
<div class="chips">
|
||
<span *ngFor="let tag of image.tags" class="chip">
|
||
{{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
|
||
</span>
|
||
</div>
|
||
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||
</section>
|
||
|
||
<section class="owner-actions" *ngIf="auth.isAuthenticated()">
|
||
<div class="add-tag">
|
||
<input
|
||
[(ngModel)]="newTagInput"
|
||
placeholder="Add tag…"
|
||
(keydown.enter)="onEnter()"
|
||
(blur)="onBlur()"
|
||
/>
|
||
</div>
|
||
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||
</section>
|
||
|
||
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
||
<div class="dialog">
|
||
<p>Permanently delete this image?</p>
|
||
<button (click)="confirmDelete()">Delete</button>
|
||
<button (click)="cancelDelete()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
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;
|
||
}
|
||
}
|
||
}
|