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() {
) : (
- {project.data.project.media.map((media) => (
- -
-
- {media.originalName}
- {formatBytes(media.sizeBytes)}
-
- {project.data.project.permissions.canManageMedia && (
-
- )}
-
- ))}
+ {isImage && (
+
+
+ open full size ↗
+
+ )}
+
+ {media.originalName}
+ {formatBytes(media.sizeBytes)}
+
+
+ {project.data.project.permissions.canManageMedia && (
+
+ )}
+
+ );
+ })}
)}
@@ -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,