Add content aggregator to home page

This commit is contained in:
Kevin Kandlbinder 2023-12-03 00:52:05 +00:00
parent f263d8c10d
commit 711547e50f
11 changed files with 477 additions and 2 deletions

3
package-lock.json generated
View file

@ -1627,7 +1627,8 @@
},
"node_modules/lucide-svelte": {
"version": "0.260.0",
"license": "ISC",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.260.0.tgz",
"integrity": "sha512-fvR/42lZdIaW9RCVIouIMO9LgxwBcE4M770Hpl1ITL2At5YamgQ8J/652mTBXnX7qfiTCND/mSVnnXGESypaEw==",
"peerDependencies": {
"svelte": ">=3 <5"
}

View file

@ -0,0 +1,120 @@
<script lang="ts">
import type { Content } from '$lib/contentTypes';
import ContentArticle from './ContentArticle.svelte';
import ContentMedia from './ContentMedia.svelte';
import { Dog } from 'lucide-svelte';
const fetchContent: () => Promise<Content[]> = async () => {
const response = await fetch('/api/v1/content/aggregate.json');
if (!response.ok) throw new Error('not ok');
return await response.json();
};
let loaded = false;
</script>
<div class="content-aggregator">
{#await fetchContent()}
<div class="loader">
<div class="icon">
<Dog />
</div>
<span>Currently fetching the content...</span>
</div>
{#each [0, 1, 2, 3, 4, 5, 6, 7, 8] as idk}
<div class="load-placeholder" />
{/each}
{:then content}
{#each content as piece}
{#if piece.type == 'media'}
<ContentMedia {piece} />
{/if}
{#if piece.type == 'article'}
<ContentArticle {piece} />
{/if}
{/each}
{:catch ex}
<span>Failed to load content...</span>
{/await}
</div>
<style lang="scss">
.content-aggregator {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: auto;
overflow: hidden;
gap: var(--gap);
position: relative;
@media (width < 600px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width < 400px) {
grid-template-columns: 1fr;
}
.loader {
position: absolute;
top: 100px;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: calc(0.5 * var(--gap));
width: 90%;
.icon {
> :global(svg) {
width: 64px;
height: 64px;
}
}
span {
font-size: 1.3em;
text-shadow: 0 0 20px white, 0 0 20px white;
text-align: center;
}
}
.load-placeholder {
background-color: rgba(122, 122, 122, 0.15);
aspect-ratio: 1/1;
border-radius: var(--radius);
animation-name: pulse;
animation-iteration-count: infinite;
animation-duration: 1s;
&:nth-child(4n-1) {
animation-delay: -250ms;
}
&:nth-child(4n-2) {
animation-delay: -500ms;
}
&:nth-child(4n-3) {
animation-delay: -750ms;
}
}
}
@keyframes pulse {
0% {
background-color: rgba(122, 122, 122, 0.15);
}
50% {
background-color: rgba(122, 122, 122, 0.05);
}
100% {
background-color: rgba(122, 122, 122, 0.15);
}
}
</style>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import type { Content } from '$lib/contentTypes';
import { baseUrl } from '$lib/seoUtils';
export let piece: Content & { type: 'article' };
</script>
<a
class="content-article"
href={piece.url}
target={piece.url.startsWith(baseUrl) ? undefined : '_blank'}
rel={piece.url.startsWith(baseUrl) ? undefined : 'noopener'}
>
<div class="backdrop">
{#if piece.feature_image}
<img src={piece.feature_image} alt={piece.title} />
{/if}
</div>
<div class="foreground">
<div class="meta">
<span class="title">{piece.title}</span>
{#if piece.read_time}
<span class="read-time">~ {piece.read_time} minute read</span>
{/if}
</div>
</div>
</a>
<style lang="scss">
.content-article {
aspect-ratio: 1/1;
position: relative;
text-decoration: none;
border-radius: var(--radius);
overflow: hidden;
.backdrop {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: black;
> img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: 0;
opacity: 0.75;
}
}
.foreground {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: flex-end;
.meta {
color: white;
text-shadow: 0 0 10px black;
padding: calc(var(--padding) * 0.5);
display: flex;
flex-direction: column;
gap: calc(var(--gap) * 0.45);
.title {
font-weight: 600;
font-size: 1.1em;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.read-time {
opacity: 0.75;
}
}
.action {
background-color: var(--color-primary-500);
color: white;
padding: calc(var(--padding) * 0.35);
text-align: center;
}
}
}
</style>

View file

@ -0,0 +1,64 @@
<script lang="ts">
import type { Content } from '$lib/contentTypes';
import { baseUrl } from '$lib/seoUtils';
import { siInstagram, siYoutube } from 'simple-icons';
export let piece: Content & { type: 'media' };
</script>
<a
class="content-media"
href={piece.url}
target={piece.url.startsWith(baseUrl) ? undefined : '_blank'}
rel={piece.url.startsWith(baseUrl) ? undefined : 'noopener'}
>
<div class="media">
<img src={piece.thumbnail_url} alt={piece.caption} />
</div>
{#if piece.source !== 'none'}
<div class="source">
{#if piece.source == 'instagram'}
{@html siInstagram.svg}
{/if}
{#if piece.source == 'youtube'}
{@html siYoutube.svg}
{/if}
</div>
{/if}
</a>
<style lang="scss">
.content-media {
aspect-ratio: 1/1;
position: relative;
.media {
width: 100%;
height: 100%;
background-color: black;
border-radius: var(--radius);
overflow: hidden;
> img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: 0;
}
}
.source {
position: absolute;
bottom: var(--gap);
right: var(--gap);
width: 25px;
height: 25px;
> :global(svg) {
fill: white;
filter: drop-shadow(0 0 5px rgb(0 0 0 / 0.5));
}
}
}
</style>

20
src/lib/contentTypes.ts Normal file
View file

@ -0,0 +1,20 @@
export type Content = {
url: string;
timestamp: string;
} & (ContentTypeMedia | ContentTypeArticle);
type ContentTypeMedia = {
type: 'media';
shape: 'square' | 'wide';
source: 'youtube' | 'instagram' | 'none';
thumbnail_url: string;
caption: string;
};
type ContentTypeArticle = {
type: 'article';
title: string;
excerpt: string;
read_time?: number;
feature_image?: string;
};

View file

@ -1,4 +1,6 @@
export const baseUrl = 'https://pupraider.net';
import { env } from '$env/dynamic/public';
export const baseUrl = env.PUBLIC_BASE_URL || 'https://pupraider.net';
export const makeCanonicalUrl = (path: string) => {
if (!path.startsWith('/')) path = '/' + path;

View file

@ -0,0 +1,120 @@
import { DateTime } from 'luxon';
import Secrets from './secrets';
import type { Content } from '$lib/contentTypes';
import { TSGhostContentAPI } from '@ts-ghost/content-api';
import { baseUrl, makeCanonicalUrl } from '$lib/seoUtils';
export type InstagramMediaResponse = {
data: {
id: string;
username: string;
caption: string;
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
media_url: string;
timestamp: string;
thumbnail_url?: string;
permalink: string;
}[];
paging: {
cursors: {
before: string;
after: string;
};
};
};
const compareContentDate = (a: Content, b: Content) => {
return (
DateTime.fromISO(b.timestamp).toUnixInteger() - DateTime.fromISO(a.timestamp).toUnixInteger()
);
};
export const aggregateContent = async () => {
let content: Content[] = [];
content.push(...(await fetchInstagramContent()));
content.push(...(await fetchBlogPosts()));
content.sort(compareContentDate);
content = content.slice(0, 9);
return content;
};
export const fetchBlogPosts = async () => {
const api = new TSGhostContentAPI(Secrets.ghost.url, Secrets.ghost.key, 'v5.41.0');
const limit = 9;
let posts = null;
try {
posts = await api.posts
.browse({
limit
})
.include({ authors: true, tags: true })
.fetch();
if (!posts.success) {
console.error(posts.errors);
throw 'Not Found';
}
} catch (e) {
throw new Error('Failed to communicate with Ghost');
}
const content: Content[] = posts.data.map((post) => {
const pubYear = post.published_at
? DateTime.fromISO(post.published_at).year.toString()
: 'other';
return {
type: 'article',
title: post.title,
excerpt: post.custom_excerpt || post.excerpt,
timestamp: post.published_at || '1999-12-31T00:00:00Z',
url: makeCanonicalUrl(`/read/${pubYear}/${post.slug}`),
feature_image: post.feature_image || undefined,
read_time: post.reading_time || 1
};
});
return content;
};
export const fetchInstagramContent = async () => {
const response = await fetch(
'https://graph.instagram.com/me/media?fields=id,username,caption,media_type,media_url,thumbnail_url,timestamp,permalink',
{
headers: {
Authorization: `Bearer ${Secrets.instagram.token}`
}
}
);
if (!response.ok) {
throw new Error('Failed to communicate with Instagram');
}
const responseJson = (await response.json()) as InstagramMediaResponse;
const content: Content[] = responseJson.data.map((post) => {
const thumbUrl = new URL(post.thumbnail_url || post.media_url);
const extension = thumbUrl.pathname.split('.').at(-1);
return {
type: 'media',
shape: 'square',
source: 'instagram',
timestamp: post.timestamp,
url: post.permalink,
thumbnail_url: makeCanonicalUrl(`/api/v1/content/media/instagram/${post.id}.${extension}`),
caption: post.caption
};
});
return content;
};

View file

@ -4,6 +4,10 @@ const Secrets = {
ghost: {
url: env.GHOST_URL || 'https://demo.ghost.io',
key: env.GHOST_CONTENT_TOKEN || '22444f78447824223cefc48062'
},
instagram: {
token: env.INSTAGRAM_TOKEN || null,
username: env.INSTAGRAM_USERNAME || null
}
};

View file

@ -0,0 +1,8 @@
import { aggregateContent } from '$lib/server/contentAgregator';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const content = await aggregateContent();
return Response.json(content);
};

View file

@ -0,0 +1,34 @@
import Secrets from '$lib/server/secrets';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const id = Number.parseInt(params.id);
const response = await fetch(
`https://graph.instagram.com/${id}?fields=id,media_url,thumbnail_url,username`,
{
headers: {
Authorization: `Bearer ${Secrets.instagram.token}`
}
}
);
const responseJson = await response.json();
console.log(responseJson);
if (responseJson.username !== Secrets.instagram.username) {
throw error(403, 'Invalid Account');
}
const mediaUrl = responseJson.thumbnail_url || responseJson.media_url;
const instagramResponse = await fetch(mediaUrl);
instagramResponse.headers.set('Cache-Control', 'public, max-age=86400');
return instagramResponse;
};
//export const prerender = true;

View file

@ -9,6 +9,7 @@
import WordMark from '$lib/components/WordMark/WordMark.svelte';
import Seo from '$lib/components/SEO/SEO.svelte';
import { makeCanonicalUrl, baseUrl } from '$lib/seoUtils';
import ContentAggregator from '$lib/components/ContentAggregator/ContentAggregator.svelte';
let isFromNFC =
typeof location === 'undefined' ? false : location?.href.indexOf('utm_medium=nfc') > -1;
@ -172,6 +173,14 @@
>
</div>
</div>
<div class="featured-content">
<h2>Latest Content</h2>
<p>Here's a selection of fresh content from all of my social media</p>
<ContentAggregator />
</div>
</div>
<style lang="scss">