From c6754d9ae12596d59f60ca940d12e5f4bc12010a Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Sun, 16 Aug 2026 17:13:31 +0200 Subject: [PATCH] feat(projects): preview image attachments Show image/* attachments directly in project detail media lists and open the original asset in a new tab when selected. Preserve download behavior for other attachment types.\n\nServe explicit image preview requests inline with a restrictive content security policy so the existing private media endpoint can provide previews without introducing resized derivatives. --- src/app/routes/ProjectDetailsPage.tsx | 60 +++++++++++++++----- src/app/styles.css | 79 +++++++++++++++++++++++++-- src/worker/repositories/media.ts | 26 +++++++-- src/worker/routes/media.ts | 4 +- test/app/routes.test.tsx | 51 +++++++++++++++++ test/media/media.test.ts | 28 +++++++++- 6 files changed, 222 insertions(+), 26 deletions(-) diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index f707dfc..8f82d7b 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -175,22 +175,48 @@ export function ProjectDetailsPage() {

) : ( )} @@ -210,6 +236,10 @@ function initials(value: string) { .toUpperCase(); } +function isImageMediaType(mediaType: string | null) { + return mediaType?.toLowerCase().startsWith('image/') ?? false; +} + function formatBytes(value: number | null) { if (value === null) return 'Size unknown'; if (value < 1024) return `${value} B`; diff --git a/src/app/styles.css b/src/app/styles.css index 733e317..01c2896 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1215,17 +1215,74 @@ main { align-items: center; border-bottom: 1px solid var(--line); } -.mediaList a { +.mediaLink { display: flex; - flex: 1; - justify-content: space-between; + min-width: 0; padding: 1rem 0; + flex: 1; + gap: 1.25rem; + align-items: center; text-decoration: none; } -.mediaList a span { +.mediaMeta { + display: flex; + min-width: 0; + flex: 1; + gap: 1rem; + align-items: center; + justify-content: space-between; +} +.mediaMeta strong { + overflow-wrap: anywhere; +} +.mediaMeta small { + flex: 0 0 auto; color: var(--muted); font-size: 0.75rem; } +.mediaPreview { + position: relative; + display: block; + width: 10rem; + aspect-ratio: 8 / 5; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--lavender); +} +.mediaPreview img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 160ms ease; +} +.mediaPreview > span { + position: absolute; + right: 0.45rem; + bottom: 0.45rem; + padding: 0.32rem 0.45rem; + color: #fff; + font-size: 0.62rem; + font-weight: 600; + border-radius: 0.35rem; + background: rgba(29, 17, 39, 0.8); + opacity: 0; + transform: translateY(0.2rem); + transition: + opacity 120ms ease, + transform 120ms ease; +} +.mediaLink:hover .mediaPreview img, +.mediaLink:focus-visible .mediaPreview img { + transform: scale(1.035); +} +.mediaLink:hover .mediaPreview > span, +.mediaLink:focus-visible .mediaPreview > span { + opacity: 1; + transform: translateY(0); +} .mediaList button { margin-left: 1.5rem; color: var(--danger); @@ -2737,6 +2794,20 @@ kbd { align-items: flex-start; flex-direction: column; } + .imageAttachment { + align-items: flex-start; + } + .imageAttachment .mediaLink, + .imageAttachment .mediaMeta { + align-items: flex-start; + flex-direction: column; + } + .mediaPreview { + width: min(12rem, 55vw); + } + .mediaList button { + margin-top: 1rem; + } .operationsBar input { width: 100%; min-width: 0; diff --git a/src/worker/repositories/media.ts b/src/worker/repositories/media.ts index f103f21..6eb5bd7 100644 --- a/src/worker/repositories/media.ts +++ b/src/worker/repositories/media.ts @@ -113,14 +113,28 @@ export async function deleteMedia( await db.prepare('DELETE FROM media WHERE id = ?').bind(mediaId).run(); } -export function attachmentHeaders(media: MediaRecord, object: R2ObjectBody) { +export function attachmentHeaders( + media: MediaRecord, + object: R2ObjectBody, + preview = false, +) { const headers = new Headers(); + const isImagePreview = preview && isImageMediaType(media.media_type); object.writeHttpMetadata(headers); headers.set('Content-Type', media.media_type || 'application/octet-stream'); headers.set('Content-Length', String(object.size)); - headers.set('Content-Disposition', contentDisposition(media.original_name)); + headers.set( + 'Content-Disposition', + contentDisposition(media.original_name, isImagePreview ? 'inline' : 'attachment'), + ); headers.set('Cache-Control', 'private, max-age=300'); headers.set('X-Content-Type-Options', 'nosniff'); + if (isImagePreview) { + headers.set( + 'Content-Security-Policy', + "sandbox; script-src 'none'; object-src 'none'; base-uri 'none'", + ); + } if (object.httpEtag) headers.set('ETag', object.httpEtag); return headers; } @@ -189,7 +203,11 @@ function mediaKey(projectId: string, mediaId: string, originalName: string) { return `projects/${projectId}/media/${mediaId}/${safeName || 'attachment'}`; } -function contentDisposition(filename: string) { +function isImageMediaType(mediaType: string | null) { + return mediaType?.toLowerCase().startsWith('image/') ?? false; +} + +function contentDisposition(filename: string, disposition: 'attachment' | 'inline') { const ascii = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '_'); - return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`; + return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`; } diff --git a/src/worker/routes/media.ts b/src/worker/routes/media.ts index 782b7df..538cc27 100644 --- a/src/worker/routes/media.ts +++ b/src/worker/routes/media.ts @@ -41,7 +41,9 @@ mediaRoutes.get('/:mediaId/content', async (c) => { c.env.ATTACHMENTS, c.req.param('mediaId'), ); - return new Response(object.body, {headers: attachmentHeaders(media, object)}); + return new Response(object.body, { + headers: attachmentHeaders(media, object, c.req.query('preview') === '1'), + }); } catch (error) { const result = errorResponse(error); return c.json(result.response, result.status); diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 8e8480e..24598e5 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -499,6 +499,57 @@ describe('clickable project routes', () => { expect(screen.getByText('')).toBeTruthy(); }); + it('previews image attachments and opens the original in a new tab', async () => { + fetchMock.mockResolvedValue( + json({ + project: { + ...projectFixture, + media: [ + { + id: 'screenshot', + originalName: 'Launch screenshot.PNG', + mediaType: 'IMAGE/PNG', + sizeBytes: 2048, + status: 'available', + createdAt: '2026-01-02', + }, + { + id: 'notes', + originalName: 'Notes.txt', + mediaType: 'text/plain', + sizeBytes: 9, + status: 'available', + createdAt: '2026-01-03', + }, + ], + }, + }), + ); + + const rendered = renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const imageLink = await screen.findByRole('link', { + name: 'Open Launch screenshot.PNG full size', + }); + expect(imageLink.getAttribute('href')).toBe( + '/api/media/screenshot/content?preview=1', + ); + expect(imageLink.getAttribute('target')).toBe('_blank'); + expect(imageLink.getAttribute('rel')).toBe('noreferrer'); + expect(imageLink.querySelector('img')?.getAttribute('src')).toBe( + '/api/media/screenshot/content?preview=1', + ); + expect(rendered.container.querySelectorAll('.mediaPreview')).toHaveLength(1); + + const notesLink = screen.getByRole('link', {name: /Notes\.txt/}); + expect(notesLink.getAttribute('href')).toBe('/api/media/notes/content'); + expect(notesLink.getAttribute('target')).toBeNull(); + }); + it('renders an idea with no video and exposes the server claim permission', async () => { fetchMock.mockResolvedValue( json({ diff --git a/test/media/media.test.ts b/test/media/media.test.ts index 37bd464..8e2bd81 100644 --- a/test/media/media.test.ts +++ b/test/media/media.test.ts @@ -66,6 +66,25 @@ describe('private project media', () => { expect(await download.text()).toBe('private bytes'); }); + it('serves image previews inline while keeping other media as downloads', async () => { + const image = await upload(memberToken, 'Screenshot.png', 'image bytes', 'image/png'); + const text = await upload(memberToken, 'Notes.txt', 'text bytes'); + + const [imagePreview, imageDownload, textPreview] = await Promise.all([ + request(`/api/media/${image.body.media.id}/content?preview=1`, memberToken), + request(`/api/media/${image.body.media.id}/content`, memberToken), + request(`/api/media/${text.body.media.id}/content?preview=1`, memberToken), + ]); + + expect(imagePreview.headers.get('Content-Type')).toBe('image/png'); + expect(imagePreview.headers.get('Content-Disposition')).toContain('inline'); + expect(imagePreview.headers.get('Content-Security-Policy')).toContain( + "script-src 'none'", + ); + expect(imageDownload.headers.get('Content-Disposition')).toContain('attachment'); + expect(textPreview.headers.get('Content-Disposition')).toContain('attachment'); + }); + it('rejects media writes from non-members and when submissions close', async () => { const outsider = await upload(outsiderToken, 'nope.txt', 'blocked'); await env.DB.prepare('UPDATE years SET submissions_closed = 1 WHERE id = ?') @@ -116,9 +135,14 @@ describe('private project media', () => { }); }); -async function upload(token: string, name: string, contents: string) { +async function upload( + token: string, + name: string, + contents: string, + mediaType = 'text/plain', +) { const data = new FormData(); - data.set('file', new File([contents], name, {type: 'text/plain'})); + data.set('file', new File([contents], name, {type: mediaType})); const response = await request(`/api/media/projects/${projectId}`, token, { method: 'POST', body: data,