feat: initial commit

This commit is contained in:
Cory Dransfeldt 2024-11-16 16:43:07 -08:00
commit 0ff7457679
No known key found for this signature in database
192 changed files with 24379 additions and 0 deletions

View file

@ -0,0 +1,48 @@
import { fetchGlobals } from "@utils/data/globals.js";
export const prerender = true;
export async function GET() {
try {
const globals = await fetchGlobals();
const webfingerResponse = {
subject: `acct:${globals.webfinger_username}@${globals.webfinger_hostname}`,
aliases: [
`https://${globals.webfinger_hostname}/@${globals.webfinger_username}`,
`https://${globals.webfinger_hostname}/users/${globals.webfinger_username}`,
],
links: [
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: `https://${globals.webfinger_hostname}/@${globals.webfinger_username}`,
},
{
rel: "self",
type: "application/activity+json",
href: `https://${globals.webfinger_hostname}/users/${globals.webfinger_username}`,
},
{
rel: "http://ostatus.org/schema/1.0/subscribe",
template: `https://${globals.webfinger_hostname}/authorize_interaction?uri={uri}`,
},
{
rel: "http://webfinger.net/rel/avatar",
type: "image/png",
href: `${globals.cdn_url}${globals.avatar}?class=squarebase`,
},
],
};
return new Response(JSON.stringify(webfingerResponse), {
status: 200,
headers: {
"Content-Type": "application/jrd+json",
},
});
} catch (error) {
console.error("Error generating WebFinger response:", error);
return new Response("Error generating WebFinger response", { status: 500 });
}
}

60
src/pages/404.astro Normal file
View file

@ -0,0 +1,60 @@
---
import Layout from "@layouts/Layout.astro";
import AddonLinks from "@components/blocks/links/AddonLinks.astro";
import { getPopularPosts } from "@utils/getPopularPosts.js";
import { fetchAllPosts } from "@data/posts.js";
import { fetchAnalyticsData } from "@data/analytics.js";
import { fetchLinks } from "@data/links.js";
import { fetchGlobalData } from "@utils/data/global/index.js";
export const prerender = true;
const [analytics, links, posts] = await Promise.all([
fetchAnalyticsData(),
fetchLinks(),
fetchAllPosts(),
]);
const popularPosts = getPopularPosts(posts, analytics);
const { globals } = await fetchGlobalData(Astro);
const pageTitle = "404";
const description = "What kind of idiots do you have working here?";
---
<Layout
pageTitle={pageTitle}
description={description}
currentUrl={Astro.url.pathname}
>
<div class="hero">
<img
srcset="
https://cdn.coryd.dev/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannersm&type=webp 256w,
https://cdn.coryd.dev/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannermd&type=webp 512w,
https://cdn.coryd.dev/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannerbase&type=webp 1024w
"
srcset={`
${globals.cdn_url}/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannersm&type=webp 256w,
${globals.cdn_url}/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannermd&type=webp 512w,
${globals.cdn_url}/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannerbase&type=webp 1024w,
`}
sizes="(max-width: 450px) 256px,
(max-width: 850px) 512px,
1024px"
src={`${globals.cdn_url}/daac2a48-455f-4b7b-a727-b33868cbb2fc.jpg?class=bannersm&type=webp`}
alt={description}
class="image-banner"
loading="lazy"
decoding="async"
width="720"
height="480"
/>
</div>
<div style="text-align:center">
<h2>{pageTitle}</h2>
<p>{description}</p>
<p><a href="/">Hurry up and skip out on the room service bill!</a></p>
</div>
<hr />
<AddonLinks popularPosts={popularPosts} links={links} />
</Layout>

View file

@ -0,0 +1,27 @@
---
import Layout from "@layouts/Layout.astro";
import BlockRenderer from "@components/blocks/BlockRenderer.astro";
import { fetchPages } from "@utils/data/pages.js";
export const prerender = true;
export async function getStaticPaths() {
const pages = await fetchPages();
return pages.map((page) => ({
params: { permalink: page.permalink },
props: { page },
}));
}
const { page } = Astro.props;
const currentUrl = Astro.url.pathname;
---
<Layout
pageTitle={page.title}
description={page.description}
ogImage={page.open_graph_image}
currentUrl={currentUrl}
>
<BlockRenderer blocks={page.blocks} />
</Layout>

97
src/pages/blogroll.astro Normal file
View file

@ -0,0 +1,97 @@
---
import Layout from "@layouts/Layout.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchBlogroll } from "@utils/data/blogroll.js";
export const prerender = true;
const {
IconRss,
IconJson,
IconMailPlus,
IconBrandMastodon,
} = icons;
const blogroll = await fetchBlogroll();
const title = "Blogroll";
const description =
"These are awesome blogs that I enjoy and you may enjoy too.";
---
<Layout
pageTitle={title}
description={description}
currentUrl={Astro.url.pathname}
>
<h2 class="page-title">{title}</h2>
<p>
You can <a
href="/blogroll.opml"
>download an OPML file</a
> containing all of these feeds and import them into your RSS reader.
</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Link</th>
<th>Subscribe</th>
</tr>
</thead>
<tbody>
{
blogroll.map((blog) => (
<tr>
<td>{blog.name}</td>
<td>
<a href={blog.url}>{blog.url.replace("https://", "")}</a>
</td>
<td class="blog-roll-icons">
{blog.rss_feed && (
<a
class="rss"
href={blog.rss_feed}
aria-label={`RSS feed for ${blog.name}`}
>
<div set:html={IconRss({ size: 16 })}/>
</a>
)}
{blog.json_feed && (
<a
class="json"
href={blog.json_feed}
aria-label={`JSON feed for ${blog.name}`}
>
<div set:html={IconJson({ size: 16 })}/>
</a>
)}
{blog.newsletter && (
<a
class="mail-plus"
href={blog.newsletter}
aria-label={`Subscribe to ${blog.name}'s newsletter`}
>
<div set:html={IconMailPlus({ size: 16 })}/>
</a>
)}
{blog.mastodon && (
<a
class="brand-mastodon"
href={blog.mastodon}
aria-label={`Follow ${blog.name} on Mastodon`}
>
<div set:html={IconBrandMastodon({ size: 16 })}/>
</a>
)}
</td>
</tr>
))
}
</tbody>
</table>
<p>
Head on over to <a href="https://blogroll.org">blogroll.org</a> to find more
blogs to follow or search for feeds using <a href="https://feedle.world"
>feedle</a
>.
</p>
</Layout>

View file

@ -0,0 +1,46 @@
import { fetchBlogroll } from "@utils/data/blogroll.js";
import { fetchGlobals } from '@utils/data/globals.js';
export const prerender = true;
export async function GET() {
try {
const blogroll = await fetchBlogroll();
const globals = await fetchGlobals();
const dateCreated = new Date().toUTCString();
const opmlContent = `
<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head>
<title>OPML for all feeds in ${globals.site_name}'s blogroll</title>
<dateCreated>${dateCreated}</dateCreated>
</head>
<body>
${blogroll
.map(
(blog) => `
<outline
text="${blog.name}"
title="${blog.name}"
type="rss"
xmlUrl="${blog.rss_feed}"
htmlUrl="${blog.url}"
/>`
)
.join("\n")}
</body>
</opml>
`.trim();
return new Response(opmlContent, {
status: 200,
headers: {
"Content-Type": "application/xml",
},
});
} catch (error) {
console.error("Error generating blogroll OPML:", error);
return new Response("Error generating blogroll OPML", { status: 500 });
}
}

View file

@ -0,0 +1,114 @@
---
import Layout from "@layouts/Layout.astro";
import Warning from "@components/blocks/banners/Warning.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import ProgressBar from "@components/media/ProgressBar.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { md } from "@utils/helpers/general.js";
const { IconArrowLeft, IconHeart, IconNeedle } = icons;
const { book, globals } = await fetchGlobalData(Astro, Astro.url.pathname);
if (!book) return Astro.redirect("/404", 404);
const alt = `${book.title}${book.author ? ` by ${book.author}` : ""}`;
const pageTitle = `${book.title} by ${book.author} / Books`;
const description =
book.description || `Details about ${book.title} by ${book.author}.`;
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={book.image}
>
<a class="back-link" href="/books" title="Go back to the books index page">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to books
</a>
<article class="book-focus">
<div class="book-display">
<img
srcset={`
${globals.cdn_url}${book.image}?class=verticalsm&type=webp 200w,
${globals.cdn_url}${book.image}?class=verticalmd&type=webp 400w,
${globals.cdn_url}${book.image}?class=verticalbase&type=webp 800w
`}
sizes="(max-width: 450px) 203px,
(max-width: 850px) 406px,
(max-width: 1000px) 812px,
812px"
src={`${globals.cdn_url}${book.image}?class=verticalsm&type=webp`}
alt={alt}
loading="lazy"
decoding="async"
width="200"
height="307"
/>
<div class="media-meta">
<span class="title"><strong>{book.title}</strong></span>
{book.rating && <span>{book.rating}</span>}
{book.author && <span class="sub-meta">By {book.author}</span>}
{
book.favorite && (
<span class="sub-meta favorite">
<div set:html={IconHeart({ size: 18 })}/> This is one of my favorite books!
</span>
)
}
{
book.tattoo && (
<span class="sub-meta tattoo">
<div set:html={IconNeedle({ size: 18 })}/> I have a tattoo inspired by this book!
</span>
)
}
{
book.status === "finished" && (
<span class="sub-meta">
Finished on:{" "}
<strong class="highlight-text">
{new Date(book.date_finished).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</strong>
</span>
)
}
{
book.status === "started" && (
<ProgressBar percentage={`${book.progress}%`} />
)
}
</div>
</div>
{
book.review && (
<>
<Warning text="There are probably spoilers after this banner — this is a warning about them." />
<h2>My thoughts</h2>
<div set:html={md(book.review)} />
</>
)
}
<AssociatedMedia
artists={book.artists}
books={book.related_books}
genres={book.genres}
movies={book.movies}
posts={book.posts}
shows={book.shows}
/>
{
book.description && (
<>
<h2>Overview</h2>
<div set:html={md(book.description)} />
</>
)
}
</article>
</Layout>

View file

@ -0,0 +1,78 @@
---
import Layout from "@layouts/Layout.astro";
import Rss from "@components/blocks/banners/Rss.astro";
import ProgressBar from "@components/media/ProgressBar.astro";
import { fetchBooks } from "@utils/data/books.js";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { md, htmlTruncate } from "@utils/helpers/general.js";
import { bookYearLinks } from "@utils/helpers/media.js";
export const prerender = true;
const books = await fetchBooks();
const currentBookCount = books.currentYear.length;
const bookData = books.all
.filter((book) => book.status === "started")
.reverse();
const { globals } = await fetchGlobalData(Astro);
const title = "Currently reading";
const description = "Here's what I'm reading at the moment.";
const updated = new Date().toISOString();
const currentYear = new Date().getFullYear();
---
<Layout
pageTitle={title}
description={description}
currentUrl={Astro.url.pathname}
ogImage={bookData[0].image}
>
<h2 class="page-title">{title}</h2>
<p>
{description} I've finished <strong class="highlight-text"
>{currentBookCount} books</strong
> this year.
</p>
<p set:html={bookYearLinks(books.years)} />
<Rss
url="/feeds/books.xml"
text="Subscribe to my books feed or follow along on this page"
/>
<hr />
{
bookData.map((book) => (
<article class="book-entry" key={book.url}>
<a href={book.url}>
<img
srcset={`
${globals.cdn_url}${book.image}?class=verticalsm&type=webp 200w,
${globals.cdn_url}${book.image}?class=verticalmd&type=webp 400w
`}
sizes="(max-width: 450px) 200px, 400px"
src={`${globals.cdn_url}${book.image}?class=verticalsm&type=webp`}
alt={`${book.title} by ${book.authors}`}
loading="lazy"
decoding="async"
width="200"
height="307"
/>
</a>
<div class="media-meta">
<a href={book.url}>
<span class="title">
<strong>{book.title}</strong>
</span>
</a>
{book.author && <span class="sub-meta">By {book.author}</span>}
{book.progress && <ProgressBar percentage={`${book.progress}%`} />}
{book.description && (
<div
class="description"
set:html={htmlTruncate(md(book.description))}
/>
)}
</div>
</article>
))
}
</Layout>

View file

@ -0,0 +1,63 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import icons from "@cdransf/astro-tabler-icons";
import {
filterBooksByStatus,
findFavoriteBooks,
mediaLinks,
} from "@utils/helpers/media.js";
import { fetchBooks } from "@utils/data/books.js";
export const prerender = true;
export async function getStaticPaths() {
const books = await fetchBooks();
return books.years.map((yearData) => ({
params: { year: String(yearData.value) },
}));
}
const { IconArrowLeft } = icons;
const books = await fetchBooks();
const { year } = Astro.params;
const yearData = books.years.find((y) => y.value === parseInt(year, 10));
if (!yearData) return Astro.redirect("/404", 404);
const bookData = filterBooksByStatus(yearData.data, "finished");
const bookDataFavorites = findFavoriteBooks(bookData);
const favoriteBooks = mediaLinks(bookDataFavorites, "book", 5);
const currentYear = new Date().getFullYear();
const isCurrentYear = parseInt(year, 10) === currentYear;
const pageTitle = `${year} / Books`;
const description = isCurrentYear
? `I've finished ${bookData.length} books this year.`
: `I finished ${bookData.length} books in ${year}.`;
const intro = isCurrentYear
? `
I've finished <strong class="highlight-text">${bookData.length} books</strong> this year.
${favoriteBooks ? ` Among my favorites are ${favoriteBooks}.` : ""}
`
: `
I finished <strong class="highlight-text">${bookData.length} books</strong> in
<strong class="highlight-text">${year}</strong>.
${favoriteBooks ? ` Among my favorites were ${favoriteBooks}.` : ""}
`;
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={bookData[0]?.image || ""}
>
<a href="/books" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to books
</a>
<h2 class="page-title">{year} / Books</h2>
<div set:html={intro} />
<hr />
<Grid data={bookData} shape="vertical" count={200} loading="eager" />
</Layout>

View file

@ -0,0 +1,23 @@
import rss from "@astrojs/rss";
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchActivity } from "@utils/data/activity.js";
export const prerender = true;
export async function GET() {
const globals = await fetchGlobals();
const activity = await fetchActivity();
return rss({
title: "coryd.dev activity feed",
description: "The latest activity from Cory Dransfeldt.",
site: globals.url,
stylesheet: `${globals.url}/feeds/style.xsl`,
items: activity.map((item) => ({
title: item.feed.title,
pubDate: item.feed.date,
link: item.feed.url,
description: item.feed.description,
})),
});
}

View file

@ -0,0 +1,24 @@
import rss from "@astrojs/rss";
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchBooks } from "@utils/data/books.js";
import { escapeHtml, md } from "@utils/helpers/general.js";
export const prerender = true;
export async function GET() {
const globals = await fetchGlobals();
const books = await fetchBooks();
return rss({
title: "coryd.dev books feed",
description: "The latest books Cory Dransfeldt has read.",
site: globals.url,
stylesheet: `${globals.url}/feeds/style.xsl`,
items: books.feed.slice(0, 20).map((book) => ({
title: book.feed.title,
pubDate: book.feed.date,
link: book.feed.url,
description: escapeHtml(md(book.feed.description)),
})),
});
}

View file

@ -0,0 +1,24 @@
import rss from "@astrojs/rss";
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchLinks } from "@utils/data/links.js";
export const prerender = true;
export async function GET() {
const globals = await fetchGlobals();
const links = await fetchLinks();
return rss({
title: "coryd.dev links feed",
description:
"These are links I've liked or otherwise found interesting. They're all added manually, after having been read and, I suppose, properly considered.",
site: globals.url,
stylesheet: `${globals.url}/feeds/style.xsl`,
items: links.slice(0, 20).map((link) => ({
title: link.feed.title,
pubDate: link.feed.date,
link: link.feed.url,
description: link.feed.description,
})),
});
}

View file

@ -0,0 +1,24 @@
import rss from "@astrojs/rss";
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchMovies } from "@utils/data/movies.js";
import { escapeHtml, md } from "@utils/helpers/general.js";
export const prerender = true;
export async function GET() {
const globals = await fetchGlobals();
const movies = await fetchMovies();
return rss({
title: "coryd.dev movies feed",
description: "The latest movie Cory Dransfeldt has watched.",
site: globals.url,
stylesheet: `${globals.url}/feeds/style.xsl`,
items: movies.feed.slice(0, 20).map((movie) => ({
title: movie.feed.title,
pubDate: movie.feed.date,
link: movie.feed.url,
description: escapeHtml(md(movie.feed.description)),
})),
});
}

View file

@ -0,0 +1,24 @@
import rss from "@astrojs/rss";
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchAllPosts } from "@utils/data/posts.js";
import { escapeHtml, md } from "@utils/helpers/general.js";
export const prerender = true;
export async function GET() {
const globals = await fetchGlobals();
const posts = await fetchAllPosts();
return rss({
title: "coryd.dev posts feed",
description: "The latest posts from Cory Dransfeldt.",
site: globals.url,
stylesheet: `${globals.url}/feeds/style.xsl`,
items: posts.slice(0, 20).map((post) => ({
title: post.feed.title,
pubDate: post.feed.date,
link: post.feed.url,
description: escapeHtml(md(post.feed.description)),
})),
});
}

View file

@ -0,0 +1,22 @@
import rss from "@astrojs/rss";
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchSyndication } from "@utils/data/syndication.js";
export const prerender = true;
export async function GET() {
const globals = await fetchGlobals();
const syndication = await fetchSyndication();
return rss({
title: "coryd.dev syndication feed",
description: "The feed that gets syndicated out to Mastodon",
site: globals.url,
items: syndication.map((item) => ({
title: item.syndication.title,
pubDate: item.syndication.date,
link: item.syndication.url,
description: item.syndication.description,
})),
});
}

31
src/pages/humans.txt.js Normal file
View file

@ -0,0 +1,31 @@
import { fetchGlobals } from '@utils/data/globals.js';
export const prerender = true;
export async function GET() {
try {
const globals = await fetchGlobals();
const humansTxt = `
## team
${globals.site_name}
${globals.url}
${globals.mastodon}
## colophon
${globals.url}/colophon
`.trim();
return new Response(humansTxt, {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
});
} catch (error) {
console.error('Error generating humans.txt:', error);
return new Response('Error generating humans.txt', { status: 500 });
}
}

17
src/pages/index.astro Normal file
View file

@ -0,0 +1,17 @@
---
import Layout from "@layouts/Layout.astro";
import Intro from "@components/home/Intro.astro";
import RecentActivity from "@components/home/RecentActivity.astro";
import RecentPosts from "@components/home/RecentPosts.astro";
import { fetchGlobalData } from "@utils/data/global/index.js";
export const prerender = true;
const { globals } = await fetchGlobalData(Astro);
---
<Layout>
<Intro intro={globals.intro} />
<RecentActivity />
<RecentPosts />
</Layout>

View file

@ -0,0 +1,76 @@
---
import Layout from "@layouts/Layout.astro";
import Paginator from "@components/nav/Paginator.astro";
import Rss from "@components/blocks/banners/Rss.astro";
import { fetchLinks } from "@utils/data/links.js";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const links = await fetchLinks();
return paginate(links, {
pageSize: 30,
});
};
const { page } = Astro.props;
const paginatedLinks = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href: i === 0 ? `/links` : `/links/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Links"
: `Links / page ${pagination.currentPage}`;
const description =
"These are links I've liked or otherwise found interesting. They're all added manually, after having been read and, I suppose, properly considered.";
---
<Layout
pageTitle={pageTitle}
description={description}
currentUrl={Astro.url.pathname}
>
{
pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description}</p>
<Rss
url="/feeds/links.xml"
text="Subscribe to my links feed or follow along on this page"
/>
<hr />
</>
)
}
<div class="link-grid">
{
paginatedLinks.map((link) => (
<div class="link-box">
<a href={link.link} title={link.title}>
<strong>{link.title}</strong>
</a>
{link.author && (
<>
{" via "}
<a href={link.author.url}>{link.author.name}</a>
</>
)}
</div>
))
}
</div>
<Paginator pagination={pagination} />
</Layout>

View file

@ -0,0 +1,180 @@
---
import { parseISO, format } from "date-fns";
import Layout from "@layouts/Layout.astro";
import Modal from "@components/blocks/Modal.astro";
import ToggleContent from "@components/utils/ToggleContent.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { parseCountries } from "@utils/helpers/media.js";
import { md } from "@utils/helpers/general.js";
const { artist, globals } = await fetchGlobalData(Astro, Astro.url.pathname);
if (!artist) return Astro.redirect("/404", 404);
const {
IconArrowLeft,
IconHeart,
IconNeedle,
IconMapPin,
IconDeviceSpeaker,
} = icons;
const pageTitle = `${artist.name} / Music`;
const description = artist.description || `Learn more about ${artist.name}`;
const alt = `${artist.name} / ${parseCountries(artist.country)}`;
const playLabel = artist.total_plays === 1 ? "play" : "plays";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
{artist.image}
>
<a href="/music" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to music
</a>
<article class="artist-focus">
<div class="artist-display">
<img
srcset={`
${globals.cdn_url}${artist.image}?class=w200&type=webp 200w,
${globals.cdn_url}${artist.image}?class=w400&type=webp 400w,
${globals.cdn_url}${artist.image}?class=w800&type=webp 800w
`}
sizes="(max-width: 450px) 200px,
(max-width: 850px) 400px,
800px"
src={`${globals.cdn_url}${artist.image}?class=w200&type=webp`}
alt={alt}
loading="eager"
decoding="async"
width="200"
height="200"
/>
<div class="media-meta">
<span class="title"><strong>{artist.name}</strong></span>
<span class="sub-meta country">
<div set:html={IconMapPin({ size: 18 })}/>
{parseCountries(artist.country)}
</span>
{
artist.favorite && (
<span class="sub-meta favorite">
<div set:html={IconHeart({ size: 18 })}/> This is one of my favorite artists!
</span>
)
}
{
artist.tattoo && (
<span class="sub-meta tattoo">
<div set:html={IconNeedle({ size: 18 })}/> I have a tattoo inspired by this artist!
</span>
)
}
{
artist.total_plays > 0 && (
<span class="sub-meta">
<strong class="highlight-text">
{artist.total_plays} {playLabel}
</strong>
</span>
)
}
<span class="sub-meta">
{artist.emoji || artist.genre_emoji}
<a
href={artist.genre.url}
title={`Learn more about ${artist.genre.name}`}
>
{artist.genre.name}
</a>
</span>
</div>
</div>
<AssociatedMedia
artists={artist.related_artists}
books={artist.books}
genres={artist.genres}
movies={artist.movies}
posts={artist.posts}
shows={artist.shows}
/>
{
artist.description && (
<>
<h2>Overview</h2>
<ToggleContent content={md(artist.description)} />
</>
)
}
{
artist.concerts && (
<>
<div id="concerts" class="concerts media-title">
<div set:html={IconDeviceSpeaker({ size: 18 })}/> I've seen this artist live!
</div>
<ul>
{artist.concerts.map((concert, index) => (
<li key={index}>
On{" "}
<strong class="highlight-text">{format(parseISO(concert.date), "MMM d, yyyy")}</strong>
{concert.venue_name_short && (
<>
{" "}
at{" "}
{concert.venue_latitude && concert.venue_longitude ? (
<a
href={`https://www.openstreetmap.org/?mlat=${concert.venue_latitude}&mlon=${concert.venue_longitude}#map=18/${concert.venue_latitude}/${concert.venue_longitude}`}
>{concert.venue_name_short}</a>
) : (
<span>{concert.venue_name_short}</span>
)}
</>
)}
{concert.notes && (
<>
{" "}
<Modal
id={`modal-${index}`}
content={`### Notes\n${concert.notes}`}
/>
</>
)}
</li>
))}
</ul>
</>
)
}
{
artist.albums && (
<>
<table>
<tr>
<th>Album</th>
<th>Plays</th>
<th>Year</th>
</tr>
{artist.albums.map((album) => (
<tr>
<td>{album.name}</td>
<td>{album.total_plays}</td>
<td>{album.release_year}</td>
</tr>
))}
</table>
<p>
<em>
These are the albums by this artist that are in my collection, not
necessarily a comprehensive discography.
</em>
</p>
</>
)
}
</article>
</Layout>

View file

@ -0,0 +1,90 @@
---
import Layout from "@layouts/Layout.astro";
import Paginator from "@components/nav/Paginator.astro";
import Modal from "@components/blocks/Modal.astro";
import { fetchConcerts } from "@utils/data/concerts.js";
import { parseISO, format } from "date-fns";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const concerts = await fetchConcerts();
return paginate(concerts, {
pageSize: 30,
});
};
const { page } = Astro.props;
const paginatedConcerts = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href: i === 0 ? `/links` : `/links/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Concerts"
: `Concerts / page ${pagination.currentPage}`;
const description =
"These are concerts I've attended (not all of them — just the ones I could remember or glean from emails, photo metadata et al). I've been to at least " +
page.data.length +
" shows.";
---
<Layout
pageTitle={pageTitle}
description={description}
currentUrl={Astro.url.pathname}
>
{page.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>These are concerts I've attended (not all of them — just the ones I could remember or glean from emails, photo metadata et al). I've been to at least <strong class="highlight-text">{page.total}</strong> shows.</p>
<hr />
</>
)}
<ul class="concert-list">
{paginatedConcerts.map((concert) => (
<li>
{concert.artist.url ? (
<a href={concert.artist.url}>{concert.artist.name}</a>
) : (
<span>{concert.artist.name}</span>
)}{" "}
on{" "}
<strong class="highlight-text">{format(parseISO(concert.date), "PPPP")}</strong>
{concert.venue?.name && (
<>
{" at "}
{concert.venue.latitude && concert.venue.longitude ? (
<a
href={`https://www.openstreetmap.org/?mlat=${concert.venue.latitude}&mlon=${concert.venue.longitude}#map=18/${concert.venue.latitude}/${concert.venue.longitude}`}
>{concert.venue.name_short || concert.venue.name}</a>
) : (
<span>{concert.venue.name_short || concert.venue.name}</span>
)}
</>
)}
{concert.notes && (
<Modal
id={`modal-${concert.id}`}
icon="info-circle"
content={`### Notes\n${concert.notes}`}
/>
)}
</li>
))}
</ul>
<Paginator pagination={pagination} />
</Layout>

View file

@ -0,0 +1,72 @@
---
import Layout from "@layouts/Layout.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { mediaLinks } from "@utils/helpers/media.js";
import { md } from "@utils/helpers/general.js";
const { genre } = await fetchGlobalData(Astro, Astro.url.pathname);
if (!genre) return Astro.redirect("/404", 404);
const { IconArrowLeft } = icons;
const artistCount = genre.artists?.length || 0;
const connectingWords = artistCount > 1 ? "artists are" : "artist is";
const genreMediaLinks = mediaLinks(genre.artists, "artist", 5);
const pageTitle = `${genre.name} / Music`;
const description = `Discover the music genre ${genre.name}, featuring ${artistCount} artists and ${genre.total_plays} total track plays.`;
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={genre.artists[0].image}
>
<a class="back-link" href="/music" title="Go back to the music index page">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to music
</a>
<h2>{genre.name}</h2>
<article class="genre-focus">
{
genreMediaLinks && (
<>
<p>
My top <strong class="highlight-text">{genre.name}</strong>{" "}
{connectingWords} <span set:html={genreMediaLinks}></span> I've listened to{" "}
<strong class="highlight-text">{genre.total_plays}</strong>{" "}
tracks from this genre.
</p>
<hr />
</>
)
}
<AssociatedMedia
books={genre.books}
movies={genre.movies}
posts={genre.posts}
/>
{
genre.description && (
<>
<h3>Overview</h3>
<div data-toggle-content class="text-toggle-hidden">
<div set:html={md(genre.description)} />
<p>
<a href={genre.wiki_link}>Continue reading at Wikipedia.</a>
</p>
<p>
<em>
Wikipedia content provided under the terms of the{" "}
<a href="https://creativecommons.org/licenses/by-sa/3.0/">
Creative Commons BY-SA license
</a>
</em>
</p>
</div>
<button data-toggle-button>Show more</button>
</>
)
}
</article>
</Layout>

124
src/pages/music/index.astro Normal file
View file

@ -0,0 +1,124 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import Recent from "@components/media/music/Recent.astro";
import Chart from "@components/media/music/Chart.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { fetchMusicWeek } from "@utils/data/music/week.js";
import { fetchAlbumReleases } from "@utils/data/albumReleases.js";
import { mediaLinks } from "@utils/helpers/media.js";
export const prerender = true;
const {
IconMicrophone2,
IconVinyl,
IconPlaylist,
IconCalendarTime,
} = icons;
const { globals } = await fetchGlobalData(Astro);
const [music, albumReleases ] = await Promise.all([
fetchMusicWeek(),
fetchAlbumReleases(),
]);
const title = "Music";
const description =
"This is everything I've been listening to recently — it's collected in a database as I listen to it and displayed here.";
---
<Layout
pageTitle={title}
description={description}
currentUrl={Astro.url.pathname}
ogImage={music.week.artists[0].artist_art}
>
<h2 class="page-title">{title}</h2>
<p>
I've listened to{" "}
<strong class="highlight-text">
{music.week.artists.length} artists
</strong>
,{" "}
<strong class="highlight-text">
{music.week.albums.length} albums
</strong>
, and{" "}
<strong class="highlight-text">
{music.week.totalTracks} tracks
</strong>{" "}
this week. Most of that has been{" "}
<span set:html={mediaLinks(music.week.genres, "genre", 5)}></span>.
</p>
<p>
<strong class="highlight-text">Take a look at what I've listened to</strong
>{" "}
<a href="/music/this-month">this month</a> or{" "}
<a href="/music/concerts">check out the concerts I've been to</a>.
</p>
<hr />
<h3 id="artists">
<a href="/music/this-week/artists">
<div set:html={IconMicrophone2({ size: 24 })}/> Artists
</a>
</h3>
<Grid
globals={globals}
data={music.week.artists}
shape="square"
count={8}
loading="eager"
/>
<h3 id="albums">
<a href="/music/this-week/artists">
<div set:html={IconVinyl({ size: 24 })}/> Albums
</a>
</h3>
<Grid globals={globals} data={music.week.albums} shape="square" count={8} />
<h3 id="tracks">
<div set:html={IconPlaylist({ size: 24 })}/> Tracks
</h3>
<div>
<input
id="tracks-recent"
name="track-options"
type="radio"
aria-hidden="true"
checked
/>
<input
id="tracks-chart"
name="track-options"
type="radio"
aria-hidden="true"
/>
<label for="tracks-recent" class="button" data-toggle="tracks-recent">
Recent
</label>
<label for="tracks-chart" class="button" data-toggle="tracks-chart">
This week
</label>
<div class="tracks-recent">
<Recent globals={globals} data={music.recent} />
</div>
<div class="tracks-chart">
<Chart data={music.week.tracks} count={10} />
</div>
</div>
{
albumReleases.upcoming.length > 0 && (
<>
<h3 id="album-releases">
<div set:html={IconCalendarTime({ size: 24 })}/> Anticipated albums
</h3>
<Grid
globals={globals}
data={albumReleases.upcoming}
shape="square"
count={8}
/>
</>
)
}
</Layout>

View file

@ -0,0 +1,27 @@
import { albumReleasesCalendar } from "@utils/albumReleasesCalendar.js";
import { fetchAlbumReleases } from "@utils/data/albumReleases.js";
export const prerender = true;
export async function GET() {
try {
const { all: albumReleases } = await fetchAlbumReleases();
const icsContent = await albumReleasesCalendar(albumReleases);
if (!icsContent)
return new Response("Error generating ICS file", { status: 500 });
return new Response(icsContent, {
status: 200,
headers: {
"Content-Type": "text/calendar",
"Content-Disposition": 'attachment; filename="releases.ics"',
},
});
} catch (error) {
console.error("Error generating album releases ICS file:", error);
return new Response("Error generating album releases ICS file", {
status: 500,
});
}
}

View file

@ -0,0 +1,71 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchMusicMonth } from "@utils/data/music/month.js";
import { shuffleArray } from "@utils/helpers/general.js";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const music = await fetchMusicMonth();
const monthlyAlbums = music.month.albums;
return paginate(monthlyAlbums, {
pageSize: 16,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedAlbums = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href:
i === 0 ? `/music/this-month/albums` : `/music/this-month/albums/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Albums I've listened to this month"
: `Albums I've listened to this month / page ${pagination.currentPage}`;
const description =
"These are the albums I've been listening to this month. All of them are awesome.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0]?.album_art}
>
<a href="/music" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to Music
</a>
{
pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} Listed in descending order from most plays to least.</p>
<p><strong class="highlight-text">You can also take a look at</strong> the <a href="/music/this-month/artists">artists I've listened to this month</a>, <a href="/music/this-week/artists">the artists I've listened to this week</a> or <a href="/music/this-week/albums">the albums I've listened to this week</a>.</p>
<p><a href="/music/concerts">I keep track of the concerts I've been to too</a>.</p>
<hr />
</>
)
}
<Grid
data={paginatedAlbums}
pagination={pagination}
shape="square"
count={16}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,69 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchMusicMonth } from "@utils/data/music/month.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const music = await fetchMusicMonth();
const monthlyArtists = music.month.artists;
return paginate(monthlyArtists, {
pageSize: 16,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedArtists = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href:
i === 0
? `/music/this-month/artists`
: `/music/this-month/artists/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Artists I've listened to this month"
: `Artists I've listened to this month / page ${pagination.currentPage}`;
const description = "These are the artists I've been listening to this month. All of them are awesome.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0]?.artist_art}
>
<a href="/music" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to Music
</a>
{pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} Listed in descending order from most plays to least.</p>
<p><strong class="highlight-text">You can also take a look at</strong> the <a href="/music/this-month/albums">albums I've listened to this month</a>, <a href="/music/this-week/artists">the artists I've listened to this week</a> or <a href="/music/this-week/albums">the albums I've listened to this week</a>.</p>
<p><a href="/music/concerts">I keep track of the concerts I've been to too</a>.</p>
<hr />
</>
)}
<Grid
data={paginatedArtists}
pagination={pagination}
shape="square"
count={16}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,71 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import Chart from "@components/media/music/Chart.astro";
import icons from "@cdransf/astro-tabler-icons";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { fetchMusicMonth } from "@utils/data/music/month.js";
import { mediaLinks } from "@utils/helpers/media.js";
export const prerender = true;
const { IconMicrophone2, IconVinyl, IconPlaylist } = icons;
const { globals } = await fetchGlobalData(Astro);
const music = await fetchMusicMonth();
const title = "This month / music";
const description =
"This is everything I've been listening to this month — it's collected in a database as I listen to it and displayed here.";
const artistCount = music.month.artists.length;
const albumCount = music.month.albums.length;
const totalTracks = music.month.totalTracks;
const topGenres = mediaLinks(music.month.genres, "genre", 5);
---
<Layout
pageTitle={title}
description={description}
fullUrl={Astro.url.pathname}
ogImage={music.month.artists[0].image}
>
<h2 class="page-title">{title}</h2>
<p>
I've listened to <strong class="highlight-text"
>{artistCount} artists</strong
>,
<strong class="highlight-text">{albumCount} albums</strong> and
<strong class="highlight-text">{totalTracks} tracks</strong> this month. Most
of that has been <span set:html={topGenres} />.
</p>
<p>
<strong class="highlight-text">Take a look at what I've listened to</strong>
<a href="/music">this week</a> or <a href="/music/concerts"
>check out the concerts I've been to</a
>.
</p>
<hr />
<h3 id="artists">
<a href="/music/this-month/artists">
<div set:html={IconMicrophone2({ size: 18 })}/> Artists
</a>
</h3>
<Grid
globals={globals}
data={music.month.artists}
shape="square"
count={8}
loading="eager"
/>
<h3 id="albums">
<a href="/music/this-month/albums">
<div set:html={IconVinyl({ size: 18 })}/> Albums
</a>
</h3>
<Grid globals={globals} data={music.month.albums} shape="square" count={8} />
<h3 id="tracks">
<div set:html={IconPlaylist({ size: 18 })}/> Tracks
</h3>
<Chart data={music.month.tracks} count={10} />
</Layout>

View file

@ -0,0 +1,71 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchMusicWeek } from "@utils/data/music/week.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const music = await fetchMusicWeek();
const weeklyAlbums = music.week.albums;
return paginate(weeklyAlbums, {
pageSize: 16,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedAlbums = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href:
i === 0 ? `/music/this-week/albums` : `/music/this-week/albums/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Albums I've listened to this week"
: `Albums I've listened to this week / page ${pagination.currentPage}`;
const description =
"These are the albums I've been listening to this week. All of them are awesome.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0]?.album_art}
>
<a href="/music" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to Music
</a>
{
pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} Listed in descending order from most plays to least.</p>
<p><strong class="highlight-text">You can also take a look at</strong> the <a href="/music/this-week/artists">artists I've listened to this week</a>, <a href="/music/this-month/artists">the artists I've listened to this month</a> or <a href="/music/this-month/albums">the albums I've listened to this month</a>.</p>
<p><a href="/music/concerts">I keep track of the concerts I've been to too</a>.</p>
<hr />
</>
)
}
<Grid
data={paginatedAlbums}
pagination={pagination}
shape="square"
count={16}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,69 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchMusicWeek } from "@utils/data/music/week.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const music = await fetchMusicWeek();
const weeklyArtists = music.week.artists;
return paginate(weeklyArtists, {
pageSize: 16,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedArtists = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href:
i === 0
? `/music/this-week/artists`
: `/music/this-week/artists/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Artists I've listened to this week"
: `Artists I've listened to this week / page ${pagination.currentPage}`;
const description = "These are the artists I've been listening to this week. All of them are awesome.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0]?.artist_art}
>
<a href="/music" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to Music
</a>
{pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} Listed in descending order from most plays to least.</p>
<p><strong class="highlight-text">You can also take a look at</strong> the <a href="/music/this-week/albums">albums I've listened to this week</a>, <a href="/music/this-month/artists">the artists I've listened to this month</a> or <a href="/music/this-month/albums">the albums I've listened to this month</a>.</p>
<p><a href="/music/concerts">I keep track of the concerts I've been to too</a>.</p>
<hr />
</>
)}
<Grid
data={paginatedArtists}
pagination={pagination}
shape="square"
count={16}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,64 @@
---
import icons from "@cdransf/astro-tabler-icons";
import { fetchAllPosts } from "@data/posts.js";
import Layout from "@layouts/Layout.astro";
import Paginator from "@components/nav/Paginator.astro";
import { md } from "@utils/helpers/general.js";
import { parseISO, format } from "date-fns";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const posts = await fetchAllPosts();
const sortedPosts = posts.sort((a, b) => new Date(b.date) - new Date(a.date));
return paginate(sortedPosts, {
pageSize: 15,
});
};
const { IconStar } = icons;
const { page } = Astro.props;
const paginatedPosts = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href: i === 0 ? `/posts` : `/posts/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Posts"
: `Posts / page ${pagination.currentPage}`;
const description =
"These are posts I've written. They're all added manually, after having been written and, I suppose, properly considered.";
---
<Layout
pageTitle={pageTitle}
description={description}
currentUrl={Astro.url.pathname}
>
{
paginatedPosts.map((post) => (
<article>
<div class="post-meta">
{post.featured && <div set:html={IconStar({ size: 16 })}/>}
<time datetime={post.date}>
{format(parseISO(post.date), "PPPP")}
</time>
</div>
<h3>
<a href={post.url}>{post.title}</a>
</h3>
<p set:html={md(post.description)} />
</article>
))
}
<Paginator pagination={pagination} />
</Layout>

View file

@ -0,0 +1,110 @@
---
import { fetchAllPosts } from "@data/posts.js";
import { fetchAnalyticsData } from "@data/analytics.js";
import { fetchLinks } from "@data/links.js";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { md } from "@utils/helpers/general.js";
import { getPopularPosts } from "@utils/getPopularPosts.js";
import icons from "@cdransf/astro-tabler-icons";
const { IconStar } = icons;
const [analytics, links, posts] = await Promise.all([
fetchAnalyticsData(),
fetchLinks(),
fetchAllPosts(),
]);
const popularPosts = getPopularPosts(posts, analytics);
import AddonLinks from "@components/blocks/links/AddonLinks.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import BlockRenderer from "@components/blocks/BlockRenderer.astro";
import Coffee from "@components/blocks/banners/Coffee.astro";
import Layout from "@layouts/Layout.astro";
import Mastodon from "@components/blocks/banners/Mastodon.astro";
import OldPost from "@components/blocks/banners/OldPost.astro";
export const prerender = true;
export async function getStaticPaths() {
const posts = await fetchAllPosts();
return posts.map((post) => {
const match = post.url.match(/^\/posts\/(\d{4})\/(.+)$/);
if (!match) throw new Error(`Invalid post URL: ${post.url}`);
const [, year, title] = match;
return {
params: { year, title },
props: { post },
};
});
}
const { post } = Astro.props;
const { globals } = await fetchGlobalData(Astro);
const currentUrl = Astro.url.pathname;
const htmlContent = md(post.content);
---
<Layout
pageTitle={post.title}
description={post.description}
ogImage={post.open_graph_image}
currentUrl={currentUrl}
>
<article class="standalone">
<div class="post-meta">
{post.featured && <div set:html={IconStar({ size: 16 })}/>}
<time datetime={post.date}>
{
new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
}
</time>
</div>
<h3>{post.title}</h3>
<div>
{post.old_post && <OldPost />}
{
post.image && (
<img
srcset={`
${globals.cdn_url}${post.image}?class=w200&type=webp 200w,
${globals.cdn_url}${post.image}?class=w400&type=webp 400w,
${globals.cdn_url}${post.image}?class=w800&type=webp 800w,
${globals.cdn_url}${post.image}?class=w1600&type=webp 1600w
`}
sizes="(max-width: 450px) 200px,
(max-width: 850px) 400px,
(max-width: 1000px) 800px,
1200px"
src={`${globals.cdn_url}${post.image}?class=w200`}
alt={post.image_alt?.replace(/['"]/g, "")}
class="image-banner"
loading="eager"
decoding="async"
width="200"
height="auto"
/>
)
}
<div set:html={htmlContent} />
{post.blocks && <BlockRenderer blocks={post.blocks} />}
{post.mastodon_url && <Mastodon url={post.mastodon_url} />}
<AssociatedMedia
artists={post.artists}
books={post.books}
genres={post.genres}
movies={post.movies}
posts={post.posts}
shows={post.shows}
/>
<Coffee />
<AddonLinks popularPosts={popularPosts} links={links} />
</div>
</article>
</Layout>

26
src/pages/robots.txt.js Normal file
View file

@ -0,0 +1,26 @@
import { fetchAllRobots } from '@utils//data/robots.js';
export const prerender = true;
export async function GET() {
try {
const robots = await fetchAllRobots();
const sitemap = `Sitemap: https://coryd.dev/sitemap-index.xml\n\n`;
const allowAll = `User-agent: *\nDisallow:\n\n`;
const disallowedBots = robots
.map((userAgent) => `User-agent: ${userAgent}`)
.join('\n');
const disallowAll = `\nDisallow: /`;
const robotsTxt = `${sitemap}${allowAll}${disallowedBots}${disallowAll}`;
return new Response(robotsTxt, {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
});
} catch (error) {
console.error('Error generating robots.txt:', error);
return new Response('Error generating robots.txt', { status: 500 });
}
}

278
src/pages/search.astro Normal file
View file

@ -0,0 +1,278 @@
---
import Layout from "@layouts/Layout.astro";
import AddonLinks from "@components/blocks/links/AddonLinks.astro";
import { getPopularPosts } from "@utils/getPopularPosts.js";
import { fetchAllPosts } from "@data/posts.js";
import { fetchAnalyticsData } from "@data/analytics.js";
import { fetchLinks } from "@data/links.js";
export const prerender = true;
const [analytics, links, posts] = await Promise.all([
fetchAnalyticsData(),
fetchLinks(),
fetchAllPosts(),
]);
const popularPosts = getPopularPosts(posts, analytics);
const title = "Search";
const description =
"Search for posts, links, artists, genres, movies, shows and books on my site.";
---
<Layout
pageTitle={title}
description={description}
currentUrl={Astro.url.pathname}
>
<h2 class="page-title">Search</h2>
<p>
You can find <a href="/posts">posts</a>, <a href="/links">links</a>, <a
href="/music/#artists">artists</a
>, genres, <a href="/watching#movies">movies</a>, <a href="/watching#tv"
>shows</a
> and <a href="/books">books</a> via the field below (though it only surfaces
movies and shows I've watched and books I've written something about).
</p>
<noscript>
<p>
<strong class="highlight-text"
>If you're seeing this it means that you've (quite likely) disabled
JavaScript (that's a totally valid choice!).</strong
> You can search for anything on my site using the form below, but your query
will be routed through <a href="https://duckduckgo.com">DuckDuckGo</a>.
</p>
<p>
<strong class="highlight-text">Type something in and hit enter.</strong>
</p>
</noscript>
<form class="search__form" action="https://duckduckgo.com" method="get">
<input
class="search__form--input"
placeholder="Search"
type="search"
name="q"
autocomplete="off"
autofocus
/>
<details>
<summary class="highlight-text">Filter by type</summary>
<fieldset class="search__form--type">
<label
><input type="checkbox" name="type" value="post" checked /> Posts</label
>
<label
><input type="checkbox" name="type" value="link" checked /> Links</label
>
<label
><input type="checkbox" name="type" value="artist" checked /> Artists</label
>
<label
><input type="checkbox" name="type" value="genre" checked /> Genres</label
>
<label
><input type="checkbox" name="type" value="book" checked /> Books</label
>
<label
><input type="checkbox" name="type" value="movie" checked /> Movies</label
>
<label
><input type="checkbox" name="type" value="show" checked /> Shows</label
>
</fieldset>
</details>
<input
class="search__form--fallback"
type="hidden"
name="sites"
value="coryd.dev"
/>
</form>
<ul class="search__results"></ul>
<button class="search__load-more" style="display:none">Load More</button>
<AddonLinks popularPosts={popularPosts} links={links} />
</Layout>
<script>
import MiniSearch from "minisearch";
window.addEventListener("load", () => {
(() => {
const miniSearch = new MiniSearch({
fields: ["title", "description", "tags", "type"],
idField: "id",
storeFields: [
"id",
"title",
"url",
"description",
"type",
"tags",
"total_plays",
],
searchOptions: {
fields: ["title", "tags"],
prefix: true,
fuzzy: 0.1,
boost: { title: 5, tags: 2, description: 1 },
},
});
const $form = document.querySelector(".search__form");
const $fallback = document.querySelector(".search__form--fallback");
const $input = document.querySelector(".search__form--input");
const $results = document.querySelector(".search__results");
const $loadMoreButton = document.querySelector(".search__load-more");
const $typeCheckboxes = document.querySelectorAll(
'.search__form--type input[type="checkbox"]'
);
$form.removeAttribute("action");
$form.removeAttribute("method");
if ($fallback) $fallback.remove();
const PAGE_SIZE = 10;
let currentPage = 1;
let currentResults = [];
let total = 0;
let debounceTimeout;
const parseMarkdown = (markdown) =>
markdown
? markdown
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, "<br>")
.replace(/[#*_~`]/g, "")
: "";
const truncateDescription = (markdown, maxLength = 150) => {
const plainText =
new DOMParser().parseFromString(parseMarkdown(markdown), "text/html")
.body.textContent || "";
return plainText.length > maxLength
? `${plainText.substring(0, maxLength)}...`
: plainText;
};
const formatArtistTitle = (title, totalPlays) =>
totalPlays > 0
? `${title} <strong class="highlight-text">${totalPlays} plays</strong>`
: title;
const renderSearchResults = (results) => {
const resultHTML = results
.map(
({ title, url, description, type, total_plays }) => `
<li class="search__results--result">
<a href="${url}">
<h3>${
type === "artist" && total_plays
? formatArtistTitle(title, total_plays)
: title
}</h3>
</a>
<p>${truncateDescription(description)}</p>
</li>
`
)
.join("");
$results.innerHTML =
resultHTML ||
'<li class="search__results--no-results">No results found.</li>';
$results.style.display = "block";
};
const loadSearchIndex = async (query, types, page) => {
try {
const typeQuery = types.join(",");
const response = await fetch(
`https://coryd.dev/api/search?q=${query}&type=${typeQuery}&page=${page}&pageSize=${PAGE_SIZE}`
);
const { results, total: newTotal } = await response.json();
total = newTotal;
const formattedResults = results.map((item) => ({
...item,
id: item.result_id,
}));
miniSearch.removeAll();
miniSearch.addAll(formattedResults);
return formattedResults;
} catch (error) {
console.error("Error fetching search data:", error);
return [];
}
};
const getSelectedTypes = () =>
Array.from($typeCheckboxes)
.filter((cb) => cb.checked)
.map((cb) => cb.value);
const updateSearchResults = (results) => {
if (currentPage === 1) {
renderSearchResults(results);
} else {
appendSearchResults(results);
}
$loadMoreButton.style.display =
currentPage * PAGE_SIZE < total ? "block" : "none";
};
const appendSearchResults = (results) => {
const newResultsHTML = results
.map(
({ title, url, description, type, total_plays }) => `
<li class="search__results--result">
<a href="${url}">
<h3>${
type === "artist" && total_plays
? formatArtistTitle(title, total_plays)
: title
}</h3>
</a>
<p>${truncateDescription(description)}</p>
</li>
`
)
.join("");
$results.insertAdjacentHTML("beforeend", newResultsHTML);
};
const handleSearch = async () => {
const query = $input.value.trim();
if (!query) {
renderSearchResults([]);
$loadMoreButton.style.display = "none";
return;
}
const results = await loadSearchIndex(query, getSelectedTypes(), 1);
currentResults = results;
currentPage = 1;
updateSearchResults(results);
};
$input.addEventListener("input", () => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(handleSearch, 300);
});
$typeCheckboxes.forEach((cb) =>
cb.addEventListener("change", handleSearch)
);
$loadMoreButton.addEventListener("click", async () => {
currentPage++;
const nextResults = await loadSearchIndex(
$input.value.trim(),
getSelectedTypes(),
currentPage
);
currentResults = [...currentResults, ...nextResults];
updateSearchResults(nextResults);
});
})();
});
</script>

View file

@ -0,0 +1,60 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchMovies } from "@utils/data/movies.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const movies = await fetchMovies();
const favoriteMovies = movies.favorites;
return paginate(favoriteMovies, {
pageSize: 15,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedMovies = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href: i === 0 ? `/watching/favorites/movies` : `/watching/favorites/movies/${i + 1}`,
})),
};
const pageTitle = pagination.currentPage === 1 ? "Favorite movies" : `Favorite movies / page ${pagination.currentPage}`;
const description = "These are my favorite movies. There are many like them, but these are mine.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0].backdrop}
>
<a href="/watching" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to watching
</a>
{pagination.currentPage === 1 && (
<>
<h2 class="page-title">Favorite Movies</h2>
<p>{description} <a href="/watching/favorites/shows">You can check out my favorite shows here.</a></p>
<hr />
</>
)}
<Grid
data={paginatedMovies}
pagination={pagination}
shape="poster"
count={15}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,60 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchShows } from "@utils/data/tv.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const shows = await fetchShows();
const favoriteShows = shows.favorites;
return paginate(favoriteShows, {
pageSize: 15,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedShows = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href: i === 0 ? `/watching/favorites/shows` : `/watching/favorites/shows/${i + 1}`,
})),
};
const pageTitle = pagination.currentPage === 1 ? "Favorite shows" : `Favorite movies / page ${pagination.currentPage}`;
const description = "These are my favorite TV shows. There are many like them, but these are mine.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0].backdrop}
>
<a href="/watching" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to watching
</a>
{pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} <a href="/watching/favorites/movies">You can check out my favorite movies here.</a></p>
<hr />
</>
)}
<Grid
data={paginatedShows}
pagination={pagination}
shape="poster"
count={15}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,71 @@
---
import Layout from "@layouts/Layout.astro";
import Hero from "@components/media/watching/Hero.astro";
import Grid from "@components/media/Grid.astro";
import Rss from "@components/blocks/banners/Rss.astro";
import { fetchMovies } from "@utils/data/movies.js";
import { fetchShows } from "@utils/data/tv.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
const { IconMovie, IconDeviceTvOld, IconStar } = icons;
const movies = await fetchMovies();
const tv = await fetchShows();
const featuredMovie = shuffleArray(movies.recentlyWatched)[0];
const favoriteMovies = shuffleArray(movies.favorites);
const favoriteShows = shuffleArray(tv.favorites);
const title = "Currently watching";
const description =
"Here's all of the TV and movies I've been watching presented in what is (hopefully) an organized fashion.";
---
<Layout
pageTitle={title}
description={description}
fullUrl={Astro.url.pathname}
ogImage={movies.recentlyWatched[0].backdrop}
>
<h2 class="page-title">{title}</h2>
<Hero movie={featuredMovie} />
<p>{description}</p>
<Rss
url="/feeds/movies.xml"
text="Subscribe to my movies feed or follow along on this page"
/>
<hr />
<h3 id="movies">
<a href="/watching/recent/movies">
<div set:html={IconMovie({ size: 18 })}/> Recent movies
</a>
</h3>
<Grid
data={movies.recentlyWatched}
shape="vertical"
count={6}
/>
<h3 id="tv">
<a href="/watching/recent/shows">
<div set:html={IconDeviceTvOld({ size: 18 })}/> Recent shows
</a>
</h3>
<Grid
data={tv.recentlyWatched}
shape="vertical"
count={6}
/>
<h3 id="favorite-movies">
<a href="/watching/favorites/movies">
<div set:html={IconStar({ size: 18 })}/> Favorite movies
</a>
</h3>
<Grid data={favoriteMovies} shape="vertical" count={6} />
<h3 id="favorite-shows">
<a href="/watching/favorites/shows">
<div set:html={IconStar({ size: 18 })}/> Favorite shows
</a>
</h3>
<Grid data={favoriteShows} shape="vertical" count={6} />
</Layout>

View file

@ -0,0 +1,120 @@
---
import { parseISO, format } from "date-fns";;
import Layout from "@layouts/Layout.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import Warning from "@components/blocks/banners/Warning.astro";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { md } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
const { globals, movie } = await fetchGlobalData(Astro, Astro.url.pathname);
if (!movie) return Astro.redirect("/404", 404);
const {
IconArrowLeft,
IconHeart,
IconNeedle,
IconCircleCheck,
} = icons;
const pageTitle = `${movie.title} / Movies`;
const description = movie.description || `Details about ${movie.title}.`;
const alt = `${movie.title} / ${movie.year}${movie.rating ? ` (${movie.rating})` : ""}`;
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={movie.backdrop}
>
<a class="back-link" href="/watching" title="Go back to the watching index page">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to watching
</a>
<article class="watching focus">
<img
srcset={`
${globals.cdn_url}${movie.backdrop}?class=bannersm&type=webp 256w,
${globals.cdn_url}${movie.backdrop}?class=bannermd&type=webp 512w,
${globals.cdn_url}${movie.backdrop}?class=bannerbase&type=webp 1024w
`}
sizes="(max-width: 450px) 256px,
(max-width: 850px) 512px,
1024px"
src={`${globals.cdn_url}${movie.backdrop}?class=bannersm&type=webp`}
alt={alt}
class="image-banner"
loading="eager"
decoding="async"
width="256"
height="180"
/>
<div class="media-meta">
<span class="title">
<strong>{movie.title}</strong>
{movie.year && !movie.rating && ` (${movie.year})`}
</span>
{
movie.rating && (
<span>
{movie.rating}
{movie.year && ` (${movie.year})`}
</span>
)
}
{
movie.favorite && (
<span class="sub-meta favorite">
<div set:html={IconHeart({ size: 18 })}/> This is one of my favorite movies!
</span>
)
}
{
movie.tattoo && (
<span class="sub-meta tattoo">
<div set:html={IconNeedle({ size: 18 })}/> I have a tattoo inspired by this movie!
</span>
)
}
{
movie.collected && (
<span class="sub-meta collected">
<div set:html={IconCircleCheck({ size: 18 })}/> This movie is in my collection!
</span>
)
}
{
movie.lastWatched && (
<span class="sub-meta">
Last watched on{" "}
{format(parseISO(movie.lastWatched), "PPPP")}
</span>
)
}
</div>
{movie.review && (
<>
<h2>My thoughts</h2>
<Warning text="There are probably spoilers after this banner — this is a warning about them." />
<div set:html={md(movie.review)}/>
</>
)}
<AssociatedMedia
artists={movie.artists}
books={movie.books}
genres={movie.genres}
movies={movie.related_movies}
posts={movie.posts}
shows={movie.shows}
/>
{movie.description && (
<>
<h2>Overview</h2>
<div set:html={md(movie.description)}/>
</>
)}
</article>
</Layout>

View file

@ -0,0 +1,66 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchMovies } from "@utils/data/movies.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const movies = await fetchMovies();
const recentlyWatchedMovies = movies.recentlyWatched;
return paginate(recentlyWatchedMovies, {
pageSize: 15,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedMovies = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href: i === 0 ? `/watching/recent/movies` : `/watching/recent/movies/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Recently watched movies"
: `Recently watched movies / page ${pagination.currentPage}`;
const description =
"All of the movies I've watched recently. Some are good, some are terrible. A few are great.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0].backdrop}
>
<a href="/watching" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to watching
</a>
{
pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} Listed in descending order from most recent to least. <a href="/watching/recent/shows">You can check out my recently watched shows here.</a></p>
<hr />
</>
)
}
<Grid
data={paginatedMovies}
pagination={pagination}
shape="poster"
count={15}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,67 @@
---
import Layout from "@layouts/Layout.astro";
import Grid from "@components/media/Grid.astro";
import { fetchShows } from "@utils/data/tv.js";
import { shuffleArray } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
export const prerender = true;
export const getStaticPaths = async ({ paginate }) => {
const shows = await fetchShows();
const recentlyWatchedShows = shows.recentlyWatched || [];
return paginate(recentlyWatchedShows, {
pageSize: 15,
});
};
const { IconArrowLeft } = icons;
const { page } = Astro.props;
const paginatedShows = page.data;
const pagination = {
currentPage: page.currentPage,
totalPages: page.lastPage,
hasPrevious: page.currentPage > 1,
hasNext: page.currentPage < page.lastPage,
previousPage: page.url.prev || null,
nextPage: page.url.next || null,
pages: Array.from({ length: page.lastPage }, (_, i) => ({
number: i + 1,
href:
i === 0 ? `/watching/recent/shows` : `/watching/recent/shows/${i + 1}`,
})),
};
const pageTitle =
pagination.currentPage === 1
? "Recently watched shows"
: `Recently watched shows / page ${pagination.currentPage}`;
const description = "All of the shows I've watched recently.";
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={shuffleArray(page.data)[0].backdrop}
>
<a href="/watching" class="back-link">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to watching
</a>
{
pagination.currentPage === 1 && (
<>
<h2 class="page-title">{pageTitle}</h2>
<p>{description} Listed in descending order from most recent to least. <a href="/watching/recent/movies">You can check out my recently watched movies here.</a></p>
<hr />
</>
)
}
<Grid
data={paginatedShows}
pagination={pagination}
shape="poster"
count={15}
loading="eager"
/>
</Layout>

View file

@ -0,0 +1,113 @@
---
import { parseISO, format } from "date-fns";
import Layout from "@layouts/Layout.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import Warning from "@components/blocks/banners/Warning.astro";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { md } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
const { globals, show } = await fetchGlobalData(Astro, Astro.url.pathname);
if (!show) return Astro.redirect("/404", 404);
const {
IconArrowLeft,
IconHeart,
IconNeedle,
IconCircleCheck,
} = icons;
const pageTitle = `${show.title} / TV`;
const description = show.description || `Details about ${show.title}.`;
const alt = `${show.title} / ${show.year}`;
---
<Layout
pageTitle={pageTitle}
description={description}
fullUrl={Astro.url.pathname}
ogImage={show.backdrop}
>
<a class="back-link" href="/watching" title="Go back to the watching index page">
<div set:html={IconArrowLeft({ size: 18 })}/> Back to watching
</a>
<article class="watching focus">
<img
srcset={`
${globals.cdn_url}${show.backdrop}?class=bannersm&type=webp 256w,
${globals.cdn_url}${show.backdrop}?class=bannermd&type=webp 512w,
${globals.cdn_url}${show.backdrop}?class=bannerbase&type=webp 1024w
`}
sizes="(max-width: 450px) 256px,
(max-width: 850px) 512px,
1024px"
src={`${globals.cdn_url}${show.backdrop}?class=bannersm&type=webp`}
alt={alt}
class="image-banner"
loading="eager"
decoding="async"
width="256"
height="180"
/>
<div class="media-meta">
<span class="title">
<strong>{show.title}</strong>
{show.year && ` (${show.year})`}
</span>
{
show.favorite && (
<span class="sub-meta favorite">
<div set:html={IconHeart({ size: 18 })}/> This is one of my favorite shows!
</span>
)
}
{
show.tattoo && (
<span class="sub-meta tattoo">
<div set:html={IconNeedle({ size: 18 })}/> I have a tattoo inspired by this show!
</span>
)
}
{
show.collected && (
<span class="sub-meta collected">
<div set:html={IconCircleCheck({ size: 18 })}/> This show is in my collection!
</span>
)
}
{
show.episode?.formatted_episode && (
<span class="sub-meta">
I last watched{" "}
<strong class="highlight-text">{show.episode.formatted_episode}</strong>{" "}
on {format(parseISO(show.episode.last_watched_at), "PPPP")}.
</span>
)
}
</div>
{show.review && (
<>
<h2>My thoughts</h2>
<Warning text="There are probably spoilers after this banner — this is a warning about them." />
<div set:html={md(show.review)}/>
</>
)}
<AssociatedMedia
artists={show.artists}
books={show.books}
genres={show.genres}
movies={show.movies}
posts={show.posts}
shows={show.related_shows}
/>
{show.description && (
<>
<h2>Overview</h2>
<div set:html={md(show.description)}/>
</>
)}
</article>
</Layout>