feat(F-055): completed feature
This commit is contained in:
105
work/artifacts/F-055/implementer.md
Normal file
105
work/artifacts/F-055/implementer.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# F-055 — Implementer Report
|
||||
|
||||
## Feature
|
||||
Image thumbnails: server-rendered cache + CSS max-size
|
||||
|
||||
## Approach
|
||||
Originally planned on-demand resize via route handler (`?w=40` / `?w=200`).
|
||||
Discovered that Next.js 16 prioritizes static files in `public/` over the
|
||||
`app/uploads/[filename]/route.ts` handler, making the query-param approach
|
||||
unworkable. Switched to pre-generating thumbnails to disk under
|
||||
`public/uploads/<width>/` and using static URLs (`/uploads/40/foo.jpg`,
|
||||
`/uploads/200/foo.jpg`) which Next.js serves as plain immutable assets.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Thumbnail pre-generation script
|
||||
|
||||
**New files:**
|
||||
- `project/scripts/generate-thumbnails.sh` — wrapper that locates sharp
|
||||
- `project/scripts/generate-thumbnails.mjs` — node script using sharp
|
||||
|
||||
The script reads source images from `apps/admin/public/uploads/` and generates
|
||||
40px and 200px thumbnails in each app's `public/uploads/40/` and
|
||||
`public/uploads/200/` directories. Existing thumbnails are skipped, so
|
||||
re-runs are idempotent and fast.
|
||||
|
||||
### 2. Sync uploads mirror thumbnails too
|
||||
|
||||
**File:** `project/scripts/monolith.sh`
|
||||
|
||||
`sync_uploads()` now mirrors `find -maxdepth 2` (covers `<app>/public/uploads/<size>/<filename>`)
|
||||
to all three apps and calls `generate-thumbnails.sh --quiet` after syncing.
|
||||
|
||||
### 3. CSS max-height on product detail pages
|
||||
|
||||
**Files:**
|
||||
- `project/storefront/src/app/productos/[slug]/page.tsx` → `max-h-[600px]`
|
||||
- `project/frontend/src/app/products/[slug]/page.tsx` → `max-h-[500px]` to image container
|
||||
|
||||
### 4. UI uses static thumbnail URLs
|
||||
|
||||
**Files:**
|
||||
- `project/apps/admin/src/features/products/components/sections/ImagesSection.tsx`
|
||||
→ `img.url.replace('/uploads/', '/uploads/200/')`
|
||||
- `project/apps/admin/src/app/(dashboard)/products/page.tsx`
|
||||
→ `p.imageUrl.replace('/uploads/', '/uploads/40/')`
|
||||
|
||||
### 5. Old dynamic route handlers removed
|
||||
|
||||
**Removed:** `app/uploads/[filename]/route.ts` in admin, frontend, and storefront.
|
||||
|
||||
Reason: Next.js 16 prioritizes static files in `public/` over route handlers,
|
||||
so the `?w=` approach silently served the original full-size image regardless
|
||||
of query params. Static URLs `/uploads/40/...` avoid the collision.
|
||||
|
||||
**Tradeoff:** Newly uploaded images (after `next start`) won't be served
|
||||
immediately because Next.js caches the `public/` listing at build/start.
|
||||
This was already the case before this change; thumbnails don't make it worse.
|
||||
|
||||
### 6. Dependencies
|
||||
|
||||
`sharp` was installed in:
|
||||
- `project/apps/admin/package.json`
|
||||
- `project/frontend/package.json`
|
||||
- `project/storefront/package.json`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Generate thumbnails for existing images
|
||||
./project/scripts/generate-thumbnails.sh
|
||||
|
||||
# Original (admin)
|
||||
curl -sI "http://localhost:3004/uploads/fa7a31db...jpg" | grep -i length
|
||||
# Content-Length: 129711 (600x800)
|
||||
|
||||
# 40px thumbnail
|
||||
curl -sI "http://localhost:3004/uploads/40/fa7a31db...jpg" | grep -i length
|
||||
# Content-Length: 1155 (40x53)
|
||||
|
||||
# 200px thumbnail
|
||||
curl -sI "http://localhost:3004/uploads/200/fa7a31db...jpg" | grep -i length
|
||||
# Content-Length: 12164 (200x267)
|
||||
|
||||
# Same in frontend (3003) and storefront (3005): identical sizes
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
- `project/scripts/monolith.sh` — sync uploads mirrors thumbnails
|
||||
- `project/scripts/generate-thumbnails.sh` — new
|
||||
- `project/scripts/generate-thumbnails.mjs` — new
|
||||
- `project/storefront/src/app/productos/[slug]/page.tsx` — max-h-[600px]
|
||||
- `project/frontend/src/app/products/[slug]/page.tsx` — max-h-[500px]
|
||||
- `project/apps/admin/src/app/(dashboard)/products/page.tsx` — /uploads/40/
|
||||
- `project/apps/admin/src/features/products/components/sections/ImagesSection.tsx` — /uploads/200/
|
||||
- `project/frontend/src/app/uploads/[filename]/route.ts` — DELETED
|
||||
- `project/storefront/src/app/uploads/[filename]/route.ts` — DELETED
|
||||
- `project/apps/admin/src/app/uploads/[filename]/route.ts` — DELETED
|
||||
|
||||
## Note on Subsequent Uploads
|
||||
After uploading a new image through admin:
|
||||
1. Image is mirrored to frontend/storefront by `api/upload/route.ts`
|
||||
2. Run `./project/scripts/generate-thumbnails.sh` to create the thumbnails
|
||||
3. Restart the affected app if you want the new image served immediately
|
||||
(otherwise it appears after the next build/start)
|
||||
13
work/artifacts/F-055/leader-close.json
Normal file
13
work/artifacts/F-055/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"feature_id": "F-055",
|
||||
"stage": "close",
|
||||
"agent": "leader",
|
||||
"ts": "2026-08-19T11:49:00Z",
|
||||
"verdict": "APPROVED",
|
||||
"gates": {
|
||||
"reviewer": "APPROVED",
|
||||
"security": "APPROVED",
|
||||
"qa": "APPROVED"
|
||||
},
|
||||
"summary": "Thumbnails pre-generated in public/uploads/40/ and public/uploads/200/, served as static files. CSS max-height on product detail pages. Admin uses 40px in product list and 200px in editor."
|
||||
}
|
||||
45
work/artifacts/F-055/qa.json
Normal file
45
work/artifacts/F-055/qa.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"feature_id": "F-055",
|
||||
"stage": "qa_gate",
|
||||
"agent": "qa",
|
||||
"ts": "2026-08-19T11:48:50Z",
|
||||
"verdict": "APPROVED",
|
||||
"checks": [
|
||||
{
|
||||
"name": "all_4_services_200",
|
||||
"status": "PASS",
|
||||
"detail": "backend 3000, frontend 3003, admin 3004, storefront 3005"
|
||||
},
|
||||
{
|
||||
"name": "original_image_serves",
|
||||
"status": "PASS",
|
||||
"detail": "GET /uploads/<uuid>.jpg returns 200 with 129711 bytes"
|
||||
},
|
||||
{
|
||||
"name": "40px_thumb_serves_all_apps",
|
||||
"status": "PASS",
|
||||
"detail": "GET /uploads/40/<uuid>.jpg returns 200 with 1155 bytes on admin, frontend, storefront"
|
||||
},
|
||||
{
|
||||
"name": "200px_thumb_serves_admin",
|
||||
"status": "PASS",
|
||||
"detail": "GET /uploads/200/<uuid>.jpg returns 200 with 12164 bytes"
|
||||
},
|
||||
{
|
||||
"name": "sync_uploads_idempotent",
|
||||
"status": "PASS",
|
||||
"detail": "generate-thumbnails.sh re-runs are no-ops for cached thumbnails"
|
||||
},
|
||||
{
|
||||
"name": "typecheck_clean",
|
||||
"status": "PASS",
|
||||
"detail": "All three apps: tsc --noEmit green"
|
||||
},
|
||||
{
|
||||
"name": "verify_sh_green",
|
||||
"status": "PASS",
|
||||
"detail": "scripts/verify.sh exit 0"
|
||||
}
|
||||
],
|
||||
"notes": "All acceptance criteria satisfied. 129KB original → 1.2KB/12KB thumbnails."
|
||||
}
|
||||
40
work/artifacts/F-055/reviewer.json
Normal file
40
work/artifacts/F-055/reviewer.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"feature_id": "F-055",
|
||||
"stage": "review_gate",
|
||||
"agent": "reviewer",
|
||||
"ts": "2026-08-19T11:48:30Z",
|
||||
"verdict": "APPROVED",
|
||||
"checks": [
|
||||
{
|
||||
"name": "thumbnail_sizes_match",
|
||||
"status": "PASS",
|
||||
"detail": "40px = 1155 bytes (40x53), 200px = 12164 bytes (200x267)"
|
||||
},
|
||||
{
|
||||
"name": "all_three_apps_serve_thumbnails",
|
||||
"status": "PASS",
|
||||
"detail": "admin:3004, frontend:3003, storefront:3005 all return correct thumbnail sizes"
|
||||
},
|
||||
{
|
||||
"name": "css_max_size_present",
|
||||
"status": "PASS",
|
||||
"detail": "storefront max-h-[600px], frontend max-h-[500px]"
|
||||
},
|
||||
{
|
||||
"name": "ui_uses_static_thumbnail_urls",
|
||||
"status": "PASS",
|
||||
"detail": "admin product list uses /uploads/40/, editor uses /uploads/200/"
|
||||
},
|
||||
{
|
||||
"name": "sync_uploads_mirrors_thumbnails",
|
||||
"status": "PASS",
|
||||
"detail": "find -maxdepth 2 ensures <width>/<filename> subdirs sync"
|
||||
},
|
||||
{
|
||||
"name": "typecheck_clean",
|
||||
"status": "PASS",
|
||||
"detail": "tsc --noEmit green for admin, frontend, storefront"
|
||||
}
|
||||
],
|
||||
"notes": "Switched from on-demand (?w=) to pre-generated static URLs because Next.js 16 prioritizes public/ static files over route handlers. Tradeoff documented: new uploads need generate-thumbnails.sh + restart to be served."
|
||||
}
|
||||
25
work/artifacts/F-055/security.json
Normal file
25
work/artifacts/F-055/security.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"feature_id": "F-055",
|
||||
"stage": "security_gate",
|
||||
"agent": "security",
|
||||
"ts": "2026-08-19T11:48:40Z",
|
||||
"verdict": "APPROVED",
|
||||
"checks": [
|
||||
{
|
||||
"name": "no_path_traversal_in_static",
|
||||
"status": "PASS",
|
||||
"detail": "static files served from public/uploads/<size>/<filename>; UUID-based filenames cannot escape"
|
||||
},
|
||||
{
|
||||
"name": "no_secrets_in_thumbnails",
|
||||
"status": "PASS",
|
||||
"detail": "Thumbnails are pure image data, no metadata preserved"
|
||||
},
|
||||
{
|
||||
"name": "sharp_processing_isolated",
|
||||
"status": "PASS",
|
||||
"detail": "sharp only reads image bytes and writes under thumbs/<width>/; no FS traversal beyond input filename"
|
||||
}
|
||||
],
|
||||
"notes": "Static serving preserves the existing security model. No new attack surface."
|
||||
}
|
||||
Reference in New Issue
Block a user