226 lines
6.6 KiB
Svelte
226 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
import { type Post, SongThumbnailImageKind } from '$lib/mastodon/response';
|
|
import type { SongInfo } from '$lib/odesliResponse';
|
|
import AvatarComponent from '$lib/components/AvatarComponent.svelte';
|
|
import AccountComponent from '$lib/components/AccountComponent.svelte';
|
|
import { secondsSince, relativeTime } from '$lib/relativeTime';
|
|
import { onMount } from 'svelte';
|
|
|
|
export let post: Post;
|
|
let displayRelativeTime = false;
|
|
const absoluteDate = new Date(post.created_at).toLocaleString();
|
|
let dateCreated = absoluteDate;
|
|
const timePassed = secondsSince(new Date(post.created_at));
|
|
$: if (displayRelativeTime) {
|
|
dateCreated = relativeTime($timePassed) ?? absoluteDate;
|
|
}
|
|
|
|
// Blurred thumbs aren't generated (yet, unclear of they ever will)
|
|
// So blurred forces using the small one, by skipping the others and removing its media query.
|
|
// This is technically unnecessary - the blurred one will only show if it matches the small media query,
|
|
// but this makes it more explicit
|
|
function getSourceSetHtml(song: SongInfo, isBlurred: boolean = false): string {
|
|
const small = new Map<string, string[]>();
|
|
const large = new Map<string, string[]>();
|
|
|
|
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
|
|
// We need the best format to be first
|
|
const formatPriority = new Map<string, number>([
|
|
['avif', 0],
|
|
['webp', 1],
|
|
['jpg', 99],
|
|
['jpeg', 99]
|
|
]);
|
|
const thumbs = (song.resizedThumbnails ?? []).sort((a, b) => {
|
|
const extensionA = a.file.split('.').pop() ?? '';
|
|
const extensionB = b.file.split('.').pop() ?? '';
|
|
const prioA = formatPriority.get(extensionA) ?? 3;
|
|
const prioB = formatPriority.get(extensionB) ?? 3;
|
|
return prioA - prioB;
|
|
});
|
|
|
|
for (const resizedThumb of thumbs) {
|
|
if (isBlurred && resizedThumb.kind !== SongThumbnailImageKind.Small) {
|
|
continue;
|
|
}
|
|
const extension = resizedThumb.file.split('.').pop();
|
|
const mime = extension ? `image/${extension}` : 'application/octet-stream';
|
|
const sourceSetEntry = `${resizedThumb.file} ${resizedThumb.sizeDescriptor}`;
|
|
switch (resizedThumb.kind) {
|
|
case SongThumbnailImageKind.Big:
|
|
large.set(mime, [...(large.get(mime) || []), sourceSetEntry]);
|
|
break;
|
|
case SongThumbnailImageKind.Small:
|
|
small.set(mime, [...(small.get(mime) || []), sourceSetEntry]);
|
|
break;
|
|
case SongThumbnailImageKind.Blurred: // currently not generated
|
|
break;
|
|
}
|
|
}
|
|
let html = '';
|
|
const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"';
|
|
for (const entry of small.entries()) {
|
|
const srcset = entry[1].join(', ');
|
|
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} />`;
|
|
}
|
|
html += '\n';
|
|
for (const entry of large.entries()) {
|
|
const srcset = entry[1].join(', ');
|
|
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
|
|
}
|
|
return html;
|
|
}
|
|
|
|
onMount(() => {
|
|
// Display relative time only after mount:
|
|
// When JS is disabled the server-side rendered absolute date will be shown,
|
|
// otherwise the relative date would be outdated very quickly
|
|
displayRelativeTime = true;
|
|
});
|
|
</script>
|
|
|
|
<div class="wrapper">
|
|
<div class="avatar"><AvatarComponent account={post.account} /></div>
|
|
<div class="account"><AccountComponent account={post.account} /></div>
|
|
<div class="meta">
|
|
<small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
|
|
</div>
|
|
<div class="content">{@html post.content}</div>
|
|
<div class="song">
|
|
{#if post.songs}
|
|
{#each post.songs as song (song.pageUrl)}
|
|
<div class="info-wrapper">
|
|
<picture>
|
|
{@html getSourceSetHtml(song)}
|
|
<img class="bgimage" src={song.thumbnailUrl} loading="lazy" alt="Blurred cover" />
|
|
</picture>
|
|
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
|
|
<div class="info">
|
|
<picture>
|
|
{@html getSourceSetHtml(song)}
|
|
<img
|
|
src={song.thumbnailUrl}
|
|
class="cover"
|
|
alt="Cover for {song.artistName} - {song.title}"
|
|
loading="lazy"
|
|
/>
|
|
</picture>
|
|
<span class="text">{song.artistName} - {song.title}</span>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.wrapper {
|
|
display: grid;
|
|
grid-template-columns: 50px 1fr auto auto;
|
|
grid-template-rows: auto 1fr auto;
|
|
grid-template-areas:
|
|
'avatar account account meta'
|
|
'avatar content content song'
|
|
'. content content song';
|
|
grid-column-gap: 6px;
|
|
column-gap: 6px;
|
|
grid-row-gap: 6px;
|
|
row-gap: 6px;
|
|
}
|
|
.avatar {
|
|
grid-area: avatar;
|
|
max-width: 50px;
|
|
max-height: 50px;
|
|
}
|
|
.account {
|
|
grid-area: account;
|
|
}
|
|
.meta {
|
|
grid-area: meta;
|
|
justify-self: end;
|
|
}
|
|
.content {
|
|
grid-area: content;
|
|
word-break: break-word;
|
|
translate: 0 -0.5em;
|
|
}
|
|
.song {
|
|
grid-area: song;
|
|
align-self: center;
|
|
justify-self: center;
|
|
max-width: 200px;
|
|
}
|
|
.cover {
|
|
max-width: 200px;
|
|
display: block;
|
|
border-radius: 3px;
|
|
margin-bottom: 3px;
|
|
}
|
|
.bgimage {
|
|
display: none;
|
|
background-color: var(--color-bg);
|
|
}
|
|
.info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5em;
|
|
z-index: 1;
|
|
}
|
|
.info * {
|
|
z-index: inherit;
|
|
}
|
|
@media only screen and (max-width: 650px) {
|
|
.wrapper {
|
|
grid-template-areas:
|
|
'avatar account account meta'
|
|
'content content content content'
|
|
'song song song song';
|
|
grid-row-gap: 3px;
|
|
row-gap: 3px;
|
|
}
|
|
.song {
|
|
width: 100%;
|
|
}
|
|
.song,
|
|
.cover {
|
|
max-width: 100%;
|
|
}
|
|
.cover {
|
|
height: 60px;
|
|
}
|
|
.cover:not(.background) {
|
|
z-index: 1;
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
}
|
|
.bgimage {
|
|
display: block;
|
|
width: 100%;
|
|
height: 60px;
|
|
z-index: 0;
|
|
filter: blur(10px);
|
|
background-repeat: no-repeat;
|
|
background-size: cover;
|
|
background-position: center;
|
|
}
|
|
.info {
|
|
position: relative;
|
|
top: -60px;
|
|
flex-direction: row;
|
|
}
|
|
.info-wrapper {
|
|
margin-bottom: -50px;
|
|
}
|
|
.text {
|
|
padding: 3px;
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
border-radius: 3px;
|
|
background-color: var(--color-bg-translucent);
|
|
color: var(--color-text);
|
|
}
|
|
}
|
|
</style>
|