fix: refine media attachment previews

This commit is contained in:
Shakker 2026-05-08 14:41:54 +01:00
parent 2157fa769a
commit 0cedf8e14b
No known key found for this signature in database
8 changed files with 470 additions and 208 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-vInMpaH9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BddjDjB3.css">
<script type="module" crossorigin src="/assets/index-DimLK6Kw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-u1LjbixA.css">
</head>
<body>
<div id="app"></div>

View File

@ -2,6 +2,7 @@
import { onDestroy, onMount, tick } from "svelte";
import { APIError, api } from "./lib/api";
import { markdown, time } from "./lib/format";
import MediaAttachment from "./components/MediaAttachment.svelte";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
let user: User | null = null;
@ -1254,32 +1255,11 @@
{#if message.attachments?.length}
<div class="attachment-grid" aria-label="Attachments">
{#each message.attachments as attachment (attachment.id)}
{#if isImageUpload(attachment)}
<button
type="button"
class="image-attachment"
aria-label={`Open image ${attachment.filename}`}
onclick={() => openImageViewer(uploadURL(attachment), attachment.filename)}
>
<img src={uploadURL(attachment)} alt={attachment.filename} loading="lazy" />
<span>{attachment.filename}</span>
</button>
{:else if isVideoUpload(attachment)}
<div class="video-attachment">
<video controls preload="metadata" aria-label={attachment.filename}>
<source src={uploadURL(attachment)} type={attachment.content_type} />
</video>
<a href={uploadURL(attachment)} target="_blank" rel="noreferrer">{attachment.filename}</a>
</div>
{:else}
<a class="file-attachment" href={uploadURL(attachment)} target="_blank" rel="noreferrer">
<span class="file-icon" aria-hidden="true"></span>
<span>
<strong>{attachment.filename}</strong>
<small>{formatBytes(attachment.byte_size)}</small>
</span>
</a>
{/if}
<MediaAttachment
upload={attachment}
url={uploadURL(attachment)}
onOpenImage={openImageViewer}
/>
{/each}
</div>
{/if}
@ -1466,32 +1446,11 @@
{#if selectedThread.attachments?.length}
<div class="attachment-grid compact" aria-label="Attachments">
{#each selectedThread.attachments as attachment (attachment.id)}
{#if isImageUpload(attachment)}
<button
type="button"
class="image-attachment"
aria-label={`Open image ${attachment.filename}`}
onclick={() => openImageViewer(uploadURL(attachment), attachment.filename)}
>
<img src={uploadURL(attachment)} alt={attachment.filename} loading="lazy" />
<span>{attachment.filename}</span>
</button>
{:else if isVideoUpload(attachment)}
<div class="video-attachment">
<video controls preload="metadata" aria-label={attachment.filename}>
<source src={uploadURL(attachment)} type={attachment.content_type} />
</video>
<a href={uploadURL(attachment)} target="_blank" rel="noreferrer">{attachment.filename}</a>
</div>
{:else}
<a class="file-attachment" href={uploadURL(attachment)} target="_blank" rel="noreferrer">
<span class="file-icon" aria-hidden="true"></span>
<span>
<strong>{attachment.filename}</strong>
<small>{formatBytes(attachment.byte_size)}</small>
</span>
</a>
{/if}
<MediaAttachment
upload={attachment}
url={uploadURL(attachment)}
onOpenImage={openImageViewer}
/>
{/each}
</div>
{/if}
@ -1545,32 +1504,11 @@
{#if reply.attachments?.length}
<div class="attachment-grid compact" aria-label="Attachments">
{#each reply.attachments as attachment (attachment.id)}
{#if isImageUpload(attachment)}
<button
type="button"
class="image-attachment"
aria-label={`Open image ${attachment.filename}`}
onclick={() => openImageViewer(uploadURL(attachment), attachment.filename)}
>
<img src={uploadURL(attachment)} alt={attachment.filename} loading="lazy" />
<span>{attachment.filename}</span>
</button>
{:else if isVideoUpload(attachment)}
<div class="video-attachment">
<video controls preload="metadata" aria-label={attachment.filename}>
<source src={uploadURL(attachment)} type={attachment.content_type} />
</video>
<a href={uploadURL(attachment)} target="_blank" rel="noreferrer">{attachment.filename}</a>
</div>
{:else}
<a class="file-attachment" href={uploadURL(attachment)} target="_blank" rel="noreferrer">
<span class="file-icon" aria-hidden="true"></span>
<span>
<strong>{attachment.filename}</strong>
<small>{formatBytes(attachment.byte_size)}</small>
</span>
</a>
{/if}
<MediaAttachment
upload={attachment}
url={uploadURL(attachment)}
onOpenImage={openImageViewer}
/>
{/each}
</div>
{/if}

View File

@ -0,0 +1,137 @@
<script lang="ts">
import type { Upload } from "../lib/types";
type Props = {
upload: Upload;
url: string;
onOpenImage?: (url: string, title: string) => void;
};
let { upload, url, onOpenImage = () => {} }: Props = $props();
let videoEl: HTMLVideoElement | null = $state(null);
let started = $state(false);
let durationLabel = $state("");
let isImage = $derived(upload.content_type?.startsWith("image/") ?? false);
let isVideo = $derived(upload.content_type?.startsWith("video/") ?? false);
function handlePlay() {
started = true;
}
function handleLoadedMetadata() {
if (!videoEl || !isFinite(videoEl.duration)) return;
const total = Math.floor(videoEl.duration);
const m = Math.floor(total / 60);
const s = total % 60;
durationLabel = `${m}:${s.toString().padStart(2, "0")}`;
}
function startPlayback() {
if (!videoEl) return;
started = true;
void videoEl.play();
}
function formatBytes(size: number) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
{#if isImage}
<div class="media-tile media-tile--image">
<button
type="button"
class="media-tile__open"
aria-label={`Open image ${upload.filename}`}
onclick={() => onOpenImage(url, upload.filename)}
>
<img src={url} alt={upload.filename} loading="lazy" />
</button>
<div class="media-tile__caption">
<span class="media-tile__name">{upload.filename}</span>
<a
class="media-tile__chip"
href={url}
download={upload.filename}
aria-label={`Download ${upload.filename}`}
onclick={(event) => event.stopPropagation()}
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0 4-4m-4 4-4-4M5 20h14"
/>
</svg>
</a>
</div>
</div>
{:else if isVideo}
<div class="media-tile media-tile--video" class:is-started={started}>
<video
bind:this={videoEl}
preload="metadata"
playsinline
controls={started}
controlslist="nodownload"
aria-label={upload.filename}
onplay={handlePlay}
onloadedmetadata={handleLoadedMetadata}
>
<source src={url} type={upload.content_type} />
</video>
{#if !started}
<button
type="button"
class="media-tile__play"
aria-label={`Play ${upload.filename}`}
onclick={startPlayback}
>
<span class="media-tile__play-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26">
<path fill="currentColor" d="M8 5.5v13l11-6.5z" />
</svg>
</span>
</button>
{#if durationLabel}
<span class="media-tile__duration" aria-hidden="true">{durationLabel}</span>
{/if}
{/if}
<div class="media-tile__caption">
<span class="media-tile__name">{upload.filename}</span>
<a
class="media-tile__chip"
href={url}
download={upload.filename}
aria-label={`Download ${upload.filename}`}
onclick={(event) => event.stopPropagation()}
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0 4-4m-4 4-4-4M5 20h14"
/>
</svg>
</a>
</div>
</div>
{:else}
<a class="file-attachment" href={url} target="_blank" rel="noreferrer">
<span class="file-icon" aria-hidden="true"></span>
<span>
<strong>{upload.filename}</strong>
<small>{formatBytes(upload.byte_size)}</small>
</span>
</a>
{/if}

View File

@ -1353,92 +1353,249 @@ button.ghost {
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 280px));
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
margin-top: 8px;
max-width: 560px;
}
.attachment-grid.compact {
grid-template-columns: minmax(0, 1fr);
max-width: 420px;
}
.image-attachment,
.video-attachment,
.file-attachment {
color: inherit;
text-decoration: none;
}
.image-attachment,
.video-attachment {
.media-tile {
position: relative;
display: block;
width: 100%;
overflow: hidden;
margin: 0;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: 0 12px 30px -24px rgba(0, 0, 0, 0.8);
background: #0a0c12;
overflow: hidden;
isolation: isolate;
text-align: left;
cursor: zoom-in;
color: inherit;
text-decoration: none;
cursor: pointer;
transition:
border-color 120ms ease,
transform 120ms ease,
box-shadow 120ms ease;
border-color 140ms ease,
transform 140ms ease,
box-shadow 140ms ease;
}
.image-attachment:hover {
border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
transform: translateY(-1px);
box-shadow: 0 16px 36px -26px rgba(0, 0, 0, 0.9);
.media-tile:hover {
border-color: var(--line-strong);
box-shadow: 0 18px 40px -28px rgba(0, 0, 0, 0.9);
}
.video-attachment {
.media-tile:focus-visible {
outline: none;
border-color: var(--line-strong);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.media-tile--image {
cursor: zoom-in;
}
.media-tile__open {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: zoom-in;
}
.media-tile__open:focus {
outline: 0;
}
.media-tile__open:focus-visible + .media-tile__caption .media-tile__name {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.media-tile--video {
cursor: default;
}
.image-attachment img {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
}
.video-attachment video {
.media-tile img,
.media-tile video {
display: block;
width: 100%;
max-height: 360px;
object-fit: contain;
background: #05070d;
}
.image-attachment span {
.media-tile--image img {
max-height: 320px;
}
.media-tile__caption {
position: absolute;
inset: auto 0 0 0;
z-index: 3;
display: flex;
align-items: center;
gap: 8px;
padding: 22px 10px 8px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.5) 40%,
rgba(0, 0, 0, 0) 100%
);
opacity: 0;
pointer-events: auto;
transition: opacity 140ms ease;
}
.media-tile:hover .media-tile__caption,
.media-tile:focus-within .media-tile__caption {
opacity: 1;
}
.media-tile--video.is-started .media-tile__caption {
display: none;
}
.media-tile__name {
flex: 1;
min-width: 0;
color: white;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.media-tile__chip {
position: relative;
z-index: 4;
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
flex: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.5);
color: white;
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
text-decoration: none;
transition:
background 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.media-tile__chip:hover {
background: rgba(0, 0, 0, 0.7);
border-color: rgba(255, 255, 255, 0.32);
transform: translateY(-1px);
}
.media-tile__play {
position: absolute;
inset: 0 0 48px 0;
display: grid;
place-items: center;
width: 100%;
height: auto;
border: 0;
margin: 0;
padding: 0;
background: transparent;
cursor: pointer;
z-index: 1;
}
.media-tile__play::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.18) 0%,
rgba(0, 0, 0, 0.05) 45%,
rgba(0, 0, 0, 0) 70%
);
opacity: 0.7;
transition: opacity 160ms ease;
pointer-events: none;
}
.media-tile__play:hover::before {
opacity: 0.9;
}
.media-tile__play-icon {
position: relative;
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: white;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 6px 20px -8px rgba(0, 0, 0, 0.55);
padding-left: 3px; /* optical centering of the play glyph */
transition:
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
background 180ms ease,
box-shadow 200ms ease;
}
.media-tile__play-icon svg {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
}
.media-tile__play:hover .media-tile__play-icon {
transform: scale(1.06);
background: rgba(0, 0, 0, 0.7);
box-shadow: 0 10px 28px -10px rgba(0, 0, 0, 0.65);
}
.media-tile__play:active .media-tile__play-icon {
transform: scale(0.97);
transition-duration: 80ms;
}
.media-tile__play:focus-visible {
outline: none;
}
.media-tile__play:focus-visible .media-tile__play-icon {
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.6),
0 10px 28px -10px rgba(0, 0, 0, 0.65);
}
.media-tile__duration {
position: absolute;
left: 8px;
bottom: 8px;
max-width: calc(100% - 16px);
padding: 4px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.58);
z-index: 2;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.72);
color: white;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(10px);
}
.video-attachment a {
display: block;
padding: 8px 10px;
color: var(--muted);
font-size: 12px;
text-decoration: none;
background: var(--panel-2);
}
.video-attachment a:hover {
color: var(--text-strong);
font-variant-numeric: tabular-nums;
font-weight: 600;
letter-spacing: 0.02em;
pointer-events: none;
}
.file-attachment {
@ -2170,6 +2327,10 @@ button.ghost {
background: transparent;
}
.modal-backdrop:focus {
outline: 0;
}
.profile-modal {
position: relative;
z-index: 1;
@ -2341,7 +2502,6 @@ button.ghost {
width: min(1120px, 100%);
max-height: min(86vh, 900px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 14px;
background: #07090f;
color: white;

View File

@ -135,7 +135,9 @@ test("sends messages, searches, uploads, opens a thread, and creates a DM", asyn
await expect(page.getByText("pixel.png")).toBeVisible();
await page.getByLabel("Message body").fill("inline image upload");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.locator(".image-attachment").filter({ hasText: "pixel.png" })).toBeVisible();
const imageAttachment = page.locator(".media-tile--image").filter({ hasText: "pixel.png" });
await expect(imageAttachment).toBeVisible();
await expect(imageAttachment.getByRole("link", { name: "Download pixel.png" })).toBeAttached();
await page.getByRole("button", { name: "Open image pixel.png" }).click();
await expect(
page.getByLabel("Image viewer").getByRole("img", { name: "pixel.png" }),
@ -153,7 +155,32 @@ test("sends messages, searches, uploads, opens a thread, and creates a DM", asyn
await expect(page.getByText("clip.mp4")).toBeVisible();
await page.getByLabel("Message body").fill("inline video upload");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.locator('.video-attachment video[aria-label="clip.mp4"]')).toBeVisible();
const videoAttachment = page.locator(".media-tile--video").filter({ hasText: "clip.mp4" });
const inlineVideo = videoAttachment.locator('video[aria-label="clip.mp4"]');
const videoDownload = videoAttachment.getByRole("link", { name: "Download clip.mp4" });
await expect(inlineVideo).toBeVisible();
await page.evaluate(() => {
(window as unknown as { __videoDownloadClicked: boolean }).__videoDownloadClicked = false;
});
await videoDownload.evaluate((node) => {
node.addEventListener(
"click",
(event) => {
event.preventDefault();
(window as unknown as { __videoDownloadClicked: boolean }).__videoDownloadClicked = true;
},
{ once: true },
);
});
await videoDownload.click();
await expect
.poll(() =>
page.evaluate(
() => (window as unknown as { __videoDownloadClicked: boolean }).__videoDownloadClicked,
),
)
.toBe(true);
await expect(inlineVideo).not.toHaveAttribute("controls", "");
await page.getByRole("button", { name: "GIF picker" }).click();
await page.getByLabel("Search GIFs").fill("ship");