Add content aggregator to home page
This commit is contained in:
parent
f263d8c10d
commit
711547e50f
11 changed files with 477 additions and 2 deletions
3
package-lock.json
generated
3
package-lock.json
generated
|
@ -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"
|
||||
}
|
||||
|
|
120
src/lib/components/ContentAggregator/ContentAggregator.svelte
Normal file
120
src/lib/components/ContentAggregator/ContentAggregator.svelte
Normal 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>
|
93
src/lib/components/ContentAggregator/ContentArticle.svelte
Normal file
93
src/lib/components/ContentAggregator/ContentArticle.svelte
Normal 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>
|
64
src/lib/components/ContentAggregator/ContentMedia.svelte
Normal file
64
src/lib/components/ContentAggregator/ContentMedia.svelte
Normal 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
20
src/lib/contentTypes.ts
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
|
|
120
src/lib/server/contentAgregator.ts
Normal file
120
src/lib/server/contentAgregator.ts
Normal 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;
|
||||
};
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
|
@ -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">
|
||||
|
|
Loading…
Add table
Reference in a new issue