Files
reactbin/ui/src/app/detail/detail.component.ts
agatha 61d923d5be Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:13:55 +00:00

265 lines
11 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';
import { ToastService } from '../services/toast.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)"
/>
<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>
<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; }
/* Copy URL */
.copy-url-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0; transition: border-color var(--transition); }
.copy-url-btn:hover { border-color: var(--border-focus); }
/* 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,
private toastService: ToastService,
) {}
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.short_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.short_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.short_id).subscribe({
next: () => this.router.navigate(['/']),
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
});
}
cancelDelete(): void {
this.showDeleteDialog = false;
this.cdr.markForCheck();
}
copyUrl(): void {
if (!this.image) return;
const url = this.image.file_url.startsWith('http')
? this.image.file_url
: window.location.origin + this.image.file_url;
navigator.clipboard.writeText(url)
.then(() => this.toastService.show('URL copied!'))
.catch(() => this.toastService.show('Failed to copy URL', 'error'));
}
goBack(): void { this.router.navigate(['/']); }
onImgError(event: Event): void {
const img = event.target as HTMLImageElement;
if (!img.src.startsWith('data:')) {
img.src = PLACEHOLDER_SVG;
}
}
}