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>
This commit is contained in:
@@ -24,7 +24,7 @@ export const routes: Routes = [
|
||||
import('./tags/tags.component').then((m) => m.TagsComponent),
|
||||
},
|
||||
{
|
||||
path: 'images/:id',
|
||||
path: 'i/:id',
|
||||
loadComponent: () =>
|
||||
import('./detail/detail.component').then((m) => m.DetailComponent),
|
||||
},
|
||||
|
||||
@@ -9,9 +9,9 @@ import { ToastService } from '../services/toast.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
const MOCK_IMAGE = {
|
||||
id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
||||
size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
|
||||
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
||||
id: 'img-1', short_id: 'AbCd1234', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
||||
size_bytes: 100, width: 10, height: 10, storage_key: 'AbCd1234',
|
||||
thumbnail_key: null, file_url: '/api/v1/i/AbCd1234/file', thumbnail_url: null,
|
||||
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||||
};
|
||||
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
|
||||
@@ -39,14 +39,14 @@ describe('DetailComponent', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
||||
component.removeTag('cat');
|
||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
|
||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['funny']);
|
||||
});
|
||||
|
||||
it('should call PATCH with new tag included on addTag', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
|
||||
component.addTag('new');
|
||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
|
||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['cat', 'funny', 'new']);
|
||||
});
|
||||
|
||||
it('should call DELETE and navigate to library on confirm delete', () => {
|
||||
@@ -55,7 +55,7 @@ describe('DetailComponent', () => {
|
||||
spyOn(router, 'navigate');
|
||||
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
||||
component.confirmDelete();
|
||||
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
|
||||
expect(imgSvc.delete).toHaveBeenCalledWith('AbCd1234');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ export class DetailComponent implements OnInit {
|
||||
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({
|
||||
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(); },
|
||||
});
|
||||
@@ -221,7 +221,7 @@ export class DetailComponent implements OnInit {
|
||||
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({
|
||||
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(); },
|
||||
});
|
||||
@@ -232,7 +232,7 @@ export class DetailComponent implements OnInit {
|
||||
|
||||
confirmDelete(): void {
|
||||
if (!this.image) return;
|
||||
this.imageService.delete(this.image.id).subscribe({
|
||||
this.imageService.delete(this.image.short_id).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
|
||||
});
|
||||
|
||||
@@ -17,15 +17,15 @@ function makeActivatedRoute(queryParams: Record<string, string> = {}) {
|
||||
|
||||
const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 };
|
||||
const ONE_IMAGE = {
|
||||
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }],
|
||||
items: [{ id: '1', short_id: 'ShrtImg1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: 'ShrtImg1', thumbnail_key: null, file_url: '/api/v1/i/ShrtImg1/file', thumbnail_url: null, created_at: '' }],
|
||||
total: 1, limit: 24, offset: 0,
|
||||
};
|
||||
const MULTI_PAGE = {
|
||||
items: Array(24).fill(null).map((_, i) => ({
|
||||
id: String(i + 1), filename: `img${i + 1}.jpg`, tags: [], hash: '',
|
||||
id: String(i + 1), short_id: `Shrt${String(i + 1).padStart(4, '0')}`, filename: `img${i + 1}.jpg`, tags: [], hash: '',
|
||||
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
|
||||
storage_key: '', thumbnail_key: null,
|
||||
file_url: `/api/v1/images/${i + 1}/file`, thumbnail_url: null, created_at: '',
|
||||
storage_key: `Shrt${String(i + 1).padStart(4, '0')}`, thumbnail_key: null,
|
||||
file_url: `/api/v1/i/Shrt${String(i + 1).padStart(4, '0')}/file`, thumbnail_url: null, created_at: '',
|
||||
})),
|
||||
total: 48, limit: 24, offset: 0,
|
||||
};
|
||||
@@ -292,4 +292,16 @@ describe('LibraryComponent', () => {
|
||||
queryParamsHandling: 'merge',
|
||||
}));
|
||||
});
|
||||
|
||||
it('clicking an image card navigates to /i/:short_id', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
|
||||
fixture.detectChanges();
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
const card = (fixture.nativeElement as HTMLElement).querySelector('.image-card') as HTMLElement;
|
||||
card.click();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,8 +70,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
class="image-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="router.navigate(['/images', img.id])"
|
||||
(keydown.enter)="router.navigate(['/images', img.id])"
|
||||
(click)="router.navigate(['/i', img.short_id])"
|
||||
(keydown.enter)="router.navigate(['/i', img.short_id])"
|
||||
>
|
||||
<img
|
||||
[src]="img.thumbnail_url ?? img.file_url"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
|
||||
|
||||
export interface ImageRecord {
|
||||
id: string;
|
||||
short_id: string;
|
||||
hash: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
@@ -50,14 +51,14 @@ export class ImageService {
|
||||
}
|
||||
|
||||
get(id: string): Observable<ImageRecord> {
|
||||
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
|
||||
return this.http.get<ImageRecord>(`${this.base}/i/${id}`);
|
||||
}
|
||||
|
||||
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
|
||||
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
|
||||
return this.http.patch<ImageRecord>(`${this.base}/i/${id}/tags`, { tags });
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/images/${id}`);
|
||||
return this.http.delete<void>(`${this.base}/i/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@ describe('UploadComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||
await component.handleUploadResponse({ id: 'abc', short_id: 'AbCd1234', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||
expect(component.toastMessage).toContain('library');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/i', 'AbCd1234']);
|
||||
});
|
||||
|
||||
it('on success response: shows success toast and navigates to detail', async () => {
|
||||
@@ -47,9 +47,9 @@ describe('UploadComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||
await component.handleUploadResponse({ id: 'xyz', short_id: 'XyZ12345', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||
expect(component.toastMessage).toBeTruthy();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/i', 'XyZ12345']);
|
||||
});
|
||||
|
||||
it('on error response: shows inline error, no navigation', async () => {
|
||||
|
||||
@@ -180,7 +180,7 @@ export class UploadComponent {
|
||||
this.cdr.markForCheck();
|
||||
}, 4000);
|
||||
}
|
||||
await this.router.navigate(['/images', res.id]);
|
||||
await this.router.navigate(['/i', res.short_id]);
|
||||
}
|
||||
|
||||
handleUploadError(err: unknown): void {
|
||||
|
||||
Reference in New Issue
Block a user