Files
reactbin/ui/src/app/detail/detail.component.ts
agatha aaacfae653 Feat: Serve images directly from Cloudflare R2 CDN
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>
2026-05-09 00:17:22 +00:00

247 lines
10 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.
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">&#x1F517;</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;
}
}
}