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,26 @@
---
import NavLink from "@components/nav/NavLink.astro";
import { fetchGlobalData } from "@utils/data/global/index.js";
const { nav } = await fetchGlobalData(Astro);
---
<footer>
<nav aria-label="Social icons" class="social">
{
nav.footer_icons.map((link) => (
<NavLink url={link.permalink} title={link.title} icon={link.icon} />
))
}
</nav>
<nav aria-label="Secondary site navigation" class="sub-pages">
{
nav.footer_text.map((link, index) => (
<>
<NavLink url={link.permalink} title={link.title} icon={link.icon} />
{index < nav.footer_text.length - 1 && <span>/</span>}
</>
))
}
</nav>
</footer>

View file

@ -0,0 +1,21 @@
---
import Menu from "@components/nav/Menu.astro";
const { siteName, url, nav } = Astro.props;
const isHomePage = url === "/";
---
<section class="main-title">
<h1>
{
isHomePage ? (
siteName
) : (
<a href="/" tabindex="0">
{siteName}
</a>
)
}
</h1>
<Menu nav={nav} />
</section>

View file

@ -0,0 +1,42 @@
---
import icons from "@cdransf/astro-tabler-icons";
const {
IconArticle,
IconHeadphones,
IconDeviceTvOld,
IconBooks,
IconLink,
IconInfoCircle,
IconSearch,
IconRss,
IconBrandMastodon,
IconMail,
IconBrandGithub,
IconBrandNpm,
IconCoffee,
IconDeviceWatch,
IconHeartHandshake,
} = icons;
const { icon, className } = Astro.props;
const iconComponents = {
article: IconArticle,
headphones: IconHeadphones,
"device-tv-old": IconDeviceTvOld,
books: IconBooks,
link: IconLink,
"info-circle": IconInfoCircle,
search: IconSearch,
rss: IconRss,
"brand-mastodon": IconBrandMastodon,
mail: IconMail,
"brand-github": IconBrandGithub,
"brand-npm": IconBrandNpm,
coffee: IconCoffee,
"device-watch": IconDeviceWatch,
"heart-handshake": IconHeartHandshake,
};
const SelectedIcon = iconComponents[icon?.toLowerCase()] || null;
---
{SelectedIcon ? <div set:html={SelectedIcon({ size: 24, className })} /> : null}

View file

@ -0,0 +1,105 @@
---
import { parseISO, format } from "date-fns";
import IconMapper from "@components/IconMapper.astro";
const {
artists = [],
books = [],
genres = [],
movies = [],
posts = [],
shows = [],
} = Astro.props;
const media = [
...(artists || []),
...(books || []),
...(genres || []),
...(movies || []),
...(posts || []),
...(shows || []),
];
if (media.length === 0) return null;
const sections = [
{
key: "artists",
icon: "headphones",
cssClass: "music",
label: "Related artist(s)",
items: artists || [],
},
{
key: "books",
icon: "books",
cssClass: "books",
label: "Related book(s)",
items: books || [],
},
{
key: "genres",
icon: "headphones",
cssClass: "music",
label: "Related genre(s)",
items: genres || [],
},
{
key: "movies",
icon: "movie",
cssClass: "movies",
label: "Related movie(s)",
items: movies || [],
},
{
key: "posts",
icon: "article",
cssClass: "article",
label: "Related post(s)",
items: posts || [],
},
{
key: "shows",
icon: "device-tv-old",
cssClass: "tv",
label: "Related show(s)",
items: shows || [],
},
];
---
<div class="associated-media">
{
sections.map(({ key, icon, cssClass, label, items }) => {
if (!items.length) return null;
return (
<section id={key} class={cssClass}>
<div class="media-title">
<IconMapper icon={icon} /> {label}
</div>
<ul>
{items.map((item) => (
<li>
<a href={item.url}>{item.title || item.name}</a>
{key === "artists" && item.total_plays > 0 && (
<strong class="highlight-text">
{item.total_plays}{" "}
{item.total_plays === 1 ? "play" : "plays"}
</strong>
)}
{key === "books" && <span>by {item.author}</span>}
{(key === "movies" || key === "shows") && (
<span>({item.year})</span>
)}
{key === "posts" && (
<span>({format(parseISO(item.date), "PPPP")})</span>
)}
</li>
))}
</ul>
</section>
);
})
}
</div>

View file

@ -0,0 +1,63 @@
---
import { fetchAllPosts } from "@data/posts.js";
import { fetchAnalyticsData } from "@data/analytics.js";
import { fetchLinks } from "@data/links.js";
import AddonLinks from "@components/blocks/links/AddonLinks.astro";
import AssociatedMedia from "@components/blocks//AssociatedMedia.astro";
import GitHub from "@components/blocks/banners/GitHub.astro";
import Hero from "@components/blocks//Hero.astro";
import Modal from "@components/blocks//Modal.astro";
import Npm from "@components/blocks/banners/Npm.astro";
import Rss from "@components/blocks/banners/Rss.astro";
import YouTubePlayer from "@components/blocks//YouTubePlayer.astro";
import { md } from "@utils/helpers/general.js";
import { getPopularPosts } from "@utils/getPopularPosts.js";
const [analytics, links, posts] = await Promise.all([
fetchAnalyticsData(),
fetchLinks(),
fetchAllPosts(),
]);
const popularPosts = getPopularPosts(posts, analytics);
const { blocks } = Astro.props;
---
<div>
{
blocks.map((block) => (
<>
{block.type === "addon_links" && (
<AddonLinks popularPosts={popularPosts} links={links} />
)}
{block.type === "associated_media" && (
<AssociatedMedia media={block.media} />
)}
{block.type === "divider" && <div set:html={md(block.markup)} />}
{block.type === "github_banner" && <GitHub url={block.url} />}
{block.type === "hero" && <Hero image={block.image} alt={block.alt} />}
{block.type === "markdown" && (
<div set:html={md(block.text)} />
)}
{block.type === "npm_banner" && (
<Npm url={block.url} command={block.command} />
)}
{block.type === "modal" && <Modal content={block.content} />}
{block.type === "rss_banner" && (
<Rss url={block.url} text={block.text} />
)}
{block.type === "youtube_player" && <YouTubePlayer url={block.url} />}
</>
))
}
</div>

View file

@ -0,0 +1,26 @@
---
import { fetchGlobalData } from "@utils/data/global/index.js";
const { image, alt } = Astro.props;
const { globals } = await fetchGlobalData(Astro);
---
<div class="hero">
<img
srcset={`
${globals.cdn_url}${image}?class=bannersm&type=webp 256w,
${globals.cdn_url}${image}?class=bannermd&type=webp 512w,
${globals.cdn_url}${image}?class=bannerbase&type=webp 1024w
`}
sizes="(max-width: 450px) 256px,
(max-width: 850px) 512px,
1024px"
src={`${globals.cdn_url}${image}?class=bannersm&type=webp`}
alt={alt}
class="image-banner"
loading="lazy"
decoding="async"
width="720"
height="480"
/>
</div>

View file

@ -0,0 +1,26 @@
---
import { md } from "@utils/helpers/general.js";
import icons from "@cdransf/astro-tabler-icons";
const { IconCircleX, IconInfoCircle } = icons;
const { content, id } = Astro.props;
---
<>
<input
class="modal-input"
id={id}
type="checkbox"
tabindex="0"
/>
<label class="modal-toggle" for={id}>
<div set:html={IconInfoCircle({ size: 24 })}/>
</label>
<div class="modal-wrapper">
<div class="modal-body">
<label class="modal-close" for={id}>
<div set:html={IconCircleX({ size: 24 })}/>
</label>
<div set:html={md(content)}/>
</div>
</div>
</>

View file

@ -0,0 +1,7 @@
---
import { YouTube } from 'astro-embed';
const { url } = Astro.props;
---
<YouTube id={url} />

View file

@ -0,0 +1,14 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconCoffee } = icons;
---
<div class="banner coffee">
<p>
<span set:html={IconCoffee({ size: 24 })} />
<a class="coffee" href="https://buymeacoffee.com/cory">
If you found this post helpful, you can buy me a coffee.
</a>
</p>
</div>

View file

@ -0,0 +1,10 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconAlertCircle } = icons;
const { text } = Astro.props;
---
<div class="banner error">
<p><span set:html={IconAlertCircle({ size: 24 })}/> {text}</p>
</div>

View file

@ -0,0 +1,14 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconBrandGithub } = icons;
const { url } = Astro.props;
---
<div class="banner github">
<p>
<span set:html={IconBrandGithub({ size: 24 })}/> Take a look at <a href={url}
>the GitHub repository for this project</a
>. (Give it a star if you feel like it.)
</p>
</div>

View file

@ -0,0 +1,13 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconBrandMastodon } = icons;
const { url } = Astro.props;
---
<div class="banner mastodon">
<p>
<span set:html={IconBrandMastodon({ size: 24 })} />
<a class="mastodon" href={url}> Discuss this post on Mastodon. </a>
</p>
</div>

View file

@ -0,0 +1,14 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconBrandNpm } = icons;
const { url, command } = Astro.props;
---
<div class="banner npm">
<p>
<span set:html={IconBrandNpm({ size: 24 })}/>
<a href={url}>You can take a look at this package on NPM</a> or install it by
running <code>{command}</code>.
</p>
</div>

View file

@ -0,0 +1,18 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconClockX } = icons;
const { isOldPost } = Astro.props;
---
{
isOldPost && (
<div class="banner old-post">
<p>
<span set:html={IconClockX({ size: 24 })}/>
This post is over 3 years old. I've probably changed my mind since it
was written and it <em>could</em> be out of date.
</p>
</div>
)
}

View file

@ -0,0 +1,13 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconRss } = icons;
const { url, text } = Astro.props;
---
<div class="banner rss">
<p>
<span set:html={IconRss({ size: 24 })}/>
<a href={url}>{text}</a>.
</p>
</div>

View file

@ -0,0 +1,13 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconAlertTriangle } = icons;
const { text } = Astro.props;
---
<div class="banner warning">
<p>
<span set:html={IconAlertTriangle({ size: 24 })}/>
{text}
</p>
</div>

View file

@ -0,0 +1,10 @@
---
import PopularPosts from './PopularPosts.astro';
import RecentLinks from './RecentLinks.astro';
const { popularPosts, links } = Astro.props;
---
<div class="addon-links">
<PopularPosts popularPosts={popularPosts} />
<RecentLinks links={links} />
</div>

View file

@ -0,0 +1,26 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconChartBarPopular } = icons;
const { popularPosts } = Astro.props;
---
{
popularPosts && popularPosts.length > 0 && (
<article>
<h3>
<a class="article" href="/posts">
<div set:html={IconChartBarPopular({ size: 24 })}/>
Popular posts
</a>
</h3>
<ol type="1">
{popularPosts.slice(0, 5).map((post) => (
<li>
<a href={post.url}>{post.title}</a>
</li>
))}
</ol>
</article>
)
}

View file

@ -0,0 +1,34 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconLink } = icons;
const { links } = Astro.props;
---
{
links && links.length > 0 && (
<article>
<h3>
<a class="link" href="/links">
<div set:html={IconLink({ size: 24 })}/>
Recent links
</a>
</h3>
<ul>
{links.slice(0, 5).map((link) => (
<li>
<a href={link.link} title={link.title}>
{link.title}
</a>
{link.author && (
<>
{" "}
via <a href={link.author.url}>{link.author.name}</a>
</>
)}
</li>
))}
</ul>
</article>
)
}

View file

@ -0,0 +1,7 @@
---
const { intro } = Astro.props;
---
<article class="intro">
<div set:html={intro} />
</article>

View file

@ -0,0 +1,76 @@
---
import { fetchBooks } from "@utils/data/books.js";
import { fetchLinks } from "@utils/data/links.js";
import { fetchMovies } from "@utils/data/movies.js";
import { fetchMusicWeek } from "@utils/data/music/week.js";
import { fetchShows } from "@utils/data/tv.js";
import icons from "@cdransf/astro-tabler-icons";
import Rss from "@components/blocks/banners/Rss.astro";
const { IconActivity } = icons;
const [music, tv, movies, books, links] = await Promise.all([
fetchMusicWeek(),
fetchShows(),
fetchMovies(),
fetchBooks(),
fetchLinks(),
]);
const artist = music.week?.artists[0];
const track = music.week?.tracks[0];
const show = tv.recentlyWatched[0];
const movie = movies.recentlyWatched[0];
const book = books.currentYear[0];
const link = links[0];
---
<article>
<h2>
<div set:html={IconActivity({ size: 24 })}/>
Recent activity
</h2>
<ul>
<li>
<span class="music">Top artist this week:</span>
<a href={artist.artist_url}>{artist.artist_name}</a>
</li>
<li>
<span class="music">Top track this week:</span>
<a href={track.artist_url}>{track.track_name} by {track.artist_name}</a>
</li>
<li>
<span class="tv">Last episode watched:</span>
<strong class="highlight-text">{show.formatted_episode}</strong> of <a
href={show.url}>{show.title}</a
>
</li>
<li>
<span class="movies">Last movie watched:</span>
<a href={movie.url}>{movie.title}</a>{
movie.rating ? ` (${movie.rating})` : ""
}
</li>
<li>
<span class="books">Last book finished:</span>
<a href={book.url}>{book.title}</a> by {book.author}{
book.rating ? ` (${book.rating})` : ""
}
</li>
<li>
<span class="link">Last link shared:</span>
<a href={link.link}>{link.title}</a>
{
link.author && (
<span>
{" "}
via <a href={link.author.url}>{link.author.name}</a>
</span>
)
}
</li>
</ul>
<Rss
url="/feeds"
text="Subscribe to my movies, books, links or activity feed(s)"
/>
</article>

View file

@ -0,0 +1,36 @@
---
import icons from "@cdransf/astro-tabler-icons";
import { fetchAllPosts } from "@utils/data/posts.js";
import { md } from "@utils/helpers/general.js";
const { IconClock, IconStar, IconArrowRight } = icons;
const posts = await fetchAllPosts();
---
<h2>
<div set:html={IconClock({ size: 24 })}/>
Recent posts
</h2>
{
posts.slice(0, 5).map((post) => (
<article key={post.url}>
<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>
<a href={post.url}>{post.title}</a>
</h3>
<p set:html={md(post.description)} />
</article>
))
}
<a class="icon-link" href="/posts">
View all posts <div set:html={IconArrowRight({ size: 16 })}/>
</a>

View file

@ -0,0 +1,75 @@
---
import Paginator from "@components/nav/Paginator.astro";
import { fetchGlobalData } from "@utils/data/global/index.js";
const { data, count, shape, pagination, loading = "lazy" } = Astro.props;
const { globals } = await fetchGlobalData(Astro);
const pageCount = pagination?.pages?.length || 0;
const hidePagination = pageCount <= 1;
function getImageAttributes(item, shape) {
let imageUrl = item.grid.image;
let imageClass = "";
let width = 0;
let height = 0;
switch (shape) {
case "poster":
imageUrl = item.grid.backdrop;
imageClass = "banner";
width = 256;
height = 170;
break;
case "square":
imageClass = "square";
width = 200;
height = 200;
break;
case "vertical":
imageClass = "vertical";
width = 200;
height = 307;
break;
}
return { imageUrl, imageClass, width, height };
}
---
<div class={`media-grid ${shape}`}>
{
data.slice(0, count).map((item) => {
const alt = item.grid.alt?.replace(/['"]/g, "");
const { imageUrl, imageClass, width, height } = getImageAttributes(
item,
shape
);
return (
<a href={item.grid.url} title={alt}>
<div class="item media-overlay">
<div class="meta-text">
<div class="header">{item.grid.title}</div>
<div class="subheader">{item.grid.subtext}</div>
</div>
<img
srcset={`
${globals.cdn_url}${imageUrl}?class=${imageClass}sm&type=webp ${width}w,
${globals.cdn_url}${imageUrl}?class=${imageClass}md&type=webp ${width * 2}w
`}
sizes={`(max-width: 450px) ${width}px, ${width * 2}px`}
src={`${globals.cdn_url}${imageUrl}?class=${imageClass}sm&type=webp`}
alt={alt}
loading={loading}
decoding="async"
width={width}
height={height}
/>
</div>
</a>
);
})
}
</div>
{!hidePagination && <Paginator pagination={pagination} />}

View file

@ -0,0 +1,9 @@
---
const { percentage } = Astro.props;
---
{percentage && (
<div class="progress-bar-wrapper" title={percentage}>
<div style={`width: ${percentage}`} class="progress-bar"/>
</div>
)}

View file

@ -0,0 +1,33 @@
---
import ProgressBar from "@components/media/ProgressBar.astro";
const { data, count } = Astro.props;
---
<div class="music-chart">
<ol type="1">
{
data.slice(0, count).map((item) => {
const percentage = `${item.chart.percentage}%`;
const playsLabel = item.chart.plays === 1 ? "play" : "plays";
return (
<li value={item.chart.rank}>
<div class="item">
<div class="info">
<a class="title" href={item.chart.url}>
{item.chart.title}
</a>
<span class="subtext">{item.chart.artist}</span>
<span class="subtext">
{item.chart.plays} {playsLabel}
</span>
</div>
<ProgressBar percentage={percentage} />
</div>
</li>
);
})
}
</ol>
</div>

View file

@ -0,0 +1,48 @@
---
import { fetchGlobalData } from "@utils/data/global/index.js";
const { data } = Astro.props;
const { globals } = await fetchGlobalData(Astro);
---
<div class="music-chart">
{
data.slice(0, 10).map((item) => (
<div class="item">
<div class="meta">
<a href={item.chart.url}>
<img
srcset={`
${globals.cdn_url}${item.chart.image}?class=w50&type=webp 50w,
${globals.cdn_url}${item.chart.image}?class=w100&type=webp 100w
`}
sizes="(max-width: 450px) 50px, 100px"
src={`${globals.cdn_url}${item.chart.image}?class=w50&type=webp`}
alt={item.chart.alt.replace(/['"]/g, "")}
loading="lazy"
decoding="async"
width="64"
height="64"
/>
</a>
<div class="meta-text">
<a class="title" href={item.chart.url}>
{item.chart.title}
</a>
<span class="subtext">{item.chart.subtext}</span>
</div>
</div>
<time datetime={item.chart.played_at}>
{new Date(item.chart.played_at).toLocaleString("en-US", {
timeZone: "America/Los_Angeles",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})}
</time>
</div>
))
}
</div>

View file

@ -0,0 +1,18 @@
---
import Hero from "@components/blocks/Hero.astro";
const { movie } = Astro.props;
---
<a href={movie.url}>
<div class="watching media-overlay hero">
<div class="meta-text">
<div class="header">{movie.title}</div>
<div class="subheader">
{movie.rating && <span class="rating">{movie.rating} </span>}
({movie.year})
</div>
</div>
<Hero image={movie.backdrop} alt={movie.title} />
</div>
</a>

View file

@ -0,0 +1,33 @@
---
import icons from "@cdransf/astro-tabler-icons";
import NavLink from "@components/nav/NavLink.astro";
const { IconMenu2, IconX } = icons
const { nav } = Astro.props;
---
<menu>
<input id="menu-toggle" type="checkbox" aria-hidden="true" />
<label class="menu-button-container" for="menu-toggle" tabindex="0">
<div class="menu-closed" aria-hidden="true">
<div set:html={IconMenu2({ size: 24 })}/>
</div>
<div class="menu-open" aria-hidden="true">
<div set:html={IconX({ size: 24 })}/>
</div>
</label>
<ul
class="menu-primary"
aria-label="Primary site navigation"
id="primary-navigation"
>
{
nav.primary.map((link) => (
<li>
<NavLink url={link.permalink} title={link.title} icon={link.icon} />
</li>
))
}
</ul>
</menu>

View file

@ -0,0 +1,27 @@
---
import IconMapper from "@components/IconMapper.astro";
import { removeTrailingSlash } from "@utils/helpers/general.js";
const { url, title, icon } = Astro.props;
const isHttp = url?.startsWith("http");
const isActive = Astro.url.pathname === removeTrailingSlash(url);
---
{
isActive ? (
<span class={`active icon ${icon?.toLowerCase()}`} aria-current="page">
<IconMapper icon={icon} />
<span>{title}</span>
</span>
) : (
<a
class={`icon ${icon}`}
href={url}
rel={isHttp ? "me" : undefined}
aria-label={title}
>
<IconMapper icon={icon} />
<span>{title}</span>
</a>
)
}

View file

@ -0,0 +1,50 @@
---
import icons from "@cdransf/astro-tabler-icons";
const { IconArrowLeft, IconArrowRight } = icons;
const { pagination } = Astro.props;
const {
currentPage,
totalPages,
hasPrevious,
hasNext,
previousPage,
nextPage,
pages,
} = pagination;
---
<nav aria-label="Pagination" class="pagination">
<a
href={hasPrevious ? previousPage : "#"}
aria-label="Previous page"
class={hasPrevious ? "" : "disabled"}
>
<div set:html={IconArrowLeft({ size: 24 })}/>
</a>
<select class="client-side" aria-label="Page selection">
{pages.map((page, index) => (
<option
value={index}
data-href={page.href}
selected={page.number === currentPage}
>
{page.number} of {totalPages}
</option>
))}
</select>
<noscript>
<p>
<span aria-current="page">{currentPage}</span> of {totalPages}
</p>
</noscript>
<a
href={hasNext ? nextPage : "#"}
aria-label="Next page"
class={hasNext ? "" : "disabled"}
>
<div set:html={IconArrowRight({ size: 24 })}/>
</a>
</nav>

View file

@ -0,0 +1,8 @@
---
const { content } = Astro.props;
---
<div data-toggle-content class="text-toggle-hidden">
<div set:html={content} />
</div>
<button data-toggle-button>Show more</button>

1
src/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

120
src/layouts/Layout.astro Normal file
View file

@ -0,0 +1,120 @@
---
import "@styles/index.css";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import { fetchGlobalData } from "@utils/data/global/index.js";
import { md, htmlToText, htmlTruncate } from "@utils/helpers/general.js";
const { globals, nav } = await fetchGlobalData(Astro);
const currentUrl = Astro.url.pathname;
const isProduction = import.meta.env.MODE === "production";
const {
schema = "page",
pageTitle = globals.site_name,
description = globals.site_description,
ogImage = globals.avatar,
fullUrl = currentUrl,
} = Astro.props;
const pageDescription = md(description);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>
{
pageTitle !== globals.site_name
? `${pageTitle} / ${globals.site_name}`
: pageTitle
}
</title>
<link
rel="preload"
href="/fonts/ml.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/fonts/mlb.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="canonical" href={`${globals.url}${fullUrl}`} />
<meta
property="og:title"
content={pageTitle !== globals.site_name
? `${pageTitle} / ${globals.site_name}`
: pageTitle}
/>
<meta
name="description"
content={htmlToText(htmlTruncate(pageDescription))}
/>
<meta
property="og:description"
content={htmlToText(htmlTruncate(pageDescription))}
/>
<meta property="og:type" content={schema || "page"} />
<meta property="og:url" content={fullUrl} />
<meta
property="og:image"
content={`${globals.cdn_url}${ogImage}?class=w800`}
/>
<meta name="theme-color" content={globals.theme_color} />
<meta name="fediverse:creator" content={globals.mastodon} />
<meta name="generator" content="Astro" />
<meta name="robots" content="noai, noimageai" />
<link
href={`${globals.cdn_url}${globals.avatar_transparent}?class=w50`}
rel="icon"
sizes="any"
/>
<link
href={`${globals.cdn_url}${globals.avatar_transparent}?class=w50&type=svg`}
rel="icon"
type="image/svg+xml"
/>
<link
href={`${globals.cdn_url}${globals.avatar}?class=w800`}
rel="apple-touch-icon"
/>
<link
type="application/atom+xml"
rel="alternate"
title={`Posts / ${globals.site_name}`}
href={`${globals.url}/feeds/posts.xml`}
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<script defer src="/scripts/index.js" is:inline></script>
{
isProduction && (
<script defer data-domain="coryd.dev" src="/js/script.js" />
)
}
<noscript>
<style>
.client-side {
display: none;
}
</style>
</noscript>
</head>
<body>
<div class="main-wrapper">
<main>
<Header siteName={globals.site_name} url={currentUrl} nav={nav} />
<div class="default-wrapper">
<slot />
</div>
</main>
<Footer />
</div>
</body>
</html>

108
src/middleware.js Normal file
View file

@ -0,0 +1,108 @@
import { fetchGlobals } from "@utils/data/globals.js";
import { fetchNavigation } from "@utils/data/nav.js";
import { fetchArtistByUrl } from "@utils/data/dynamic/artistByUrl.js";
import { fetchBookByUrl } from "@utils/data/dynamic/bookByUrl.js";
import { fetchGenreByUrl } from "@utils/data/dynamic/genreByUrl.js";
import { fetchMovieByUrl } from "@utils/data/dynamic/movieByUrl.js";
import { fetchShowByUrl } from "@utils/data/dynamic/showByUrl.js";
import { isbnRegex } from "@utils/helpers/media.js";
import { isExcludedPath } from "@utils/helpers/general.js";
import { CACHE_DURATION } from "@utils/constants/index.js";
let cachedGlobals = null;
let cachedNav = null;
let cachedByType = {};
let lastFetchTimeGlobalsNav = 0;
let lastFetchTimeByType = {};
export async function onRequest(context, next) {
const now = Date.now();
const { request, locals } = context;
try {
const runtimeEnv = locals.runtime?.env;
if (!runtimeEnv)
return new Response("Internal Server Error", { status: 500 });
const urlPath = new URL(request.url).pathname;
if (
!cachedGlobals ||
!cachedNav ||
now - lastFetchTimeGlobalsNav > CACHE_DURATION
) {
const [globals, nav] = await Promise.all([
fetchGlobals(runtimeEnv),
fetchNavigation(runtimeEnv),
]);
cachedGlobals = globals;
cachedNav = nav;
lastFetchTimeGlobalsNav = now;
}
let resourceType = null;
if (urlPath.startsWith("/music/artists/")) resourceType = "artist";
else if (isbnRegex.test(urlPath)) resourceType = "book";
else if (urlPath.startsWith("/music/genres/")) resourceType = "genre";
else if (
urlPath.startsWith("/watching/movies/") &&
!isExcludedPath(urlPath, ["/favorites", "/recent"])
)
resourceType = "movie";
else if (
urlPath.startsWith("/watching/shows/") &&
!isExcludedPath(urlPath, ["/favorites", "/recent"])
)
resourceType = "show";
if (resourceType) {
if (
!cachedByType[urlPath] ||
now - (lastFetchTimeByType[urlPath] || 0) > CACHE_DURATION
) {
let fetchFunction = null;
switch (resourceType) {
case "artist":
fetchFunction = fetchArtistByUrl;
break;
case "book":
fetchFunction = fetchBookByUrl;
break;
case "genre":
fetchFunction = fetchGenreByUrl;
break;
case "movie":
fetchFunction = fetchMovieByUrl;
break;
case "show":
fetchFunction = fetchShowByUrl;
break;
}
const data = await fetchFunction(runtimeEnv, urlPath);
if (!data)
return new Response(
`${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)} Not Found`,
{ status: 404 }
);
cachedByType[urlPath] = data;
lastFetchTimeByType[urlPath] = now;
}
locals[resourceType] = cachedByType[urlPath];
}
locals.globals = cachedGlobals;
locals.nav = cachedNav;
} catch (error) {
console.error("Error in middleware:", error);
return new Response("Internal Server Error", { status: 500 });
}
return next();
}

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>

31
src/styles/base/fonts.css Normal file
View file

@ -0,0 +1,31 @@
@font-face {
font-family: MonoLisa;
src: url("/fonts/ml.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: MonoLisa;
src: url("/fonts/mlb.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: MonoLisa;
src: url("/fonts/mli.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: MonoLisa;
src: url("/fonts/mlbi.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}

512
src/styles/base/index.css Normal file
View file

@ -0,0 +1,512 @@
html,
body {
font-family: var(--font-mono);
color: var(--text-color);
background: var(--background-color);
}
html {
scrollbar-color: var(--accent-color) var(--gray-light);
}
::-webkit-scrollbar {
width: var(--sizing-md);
}
::-webkit-scrollbar-track {
background: var(--gray-light);
}
::-webkit-scrollbar-thumb {
background: var(--accent-color);
border-radius: var(--border-radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-color-hover);
}
::selection {
color: var(--color-lightest);
background: var(--accent-color);
}
p {
margin: var(--margin-vertical-base-horizontal-zero);
}
.highlight-text {
color: var(--text-color-inverted);
background-color: var(--accent-color);
padding: var(--spacing-xs);
border-radius: var(--border-radius-slight);
}
details > summary {
display: inline;
}
blockquote {
font-size: var(--font-size-lg);
color: var(--gray-dark);
padding-left: var(--spacing-lg);
border-left: var(--sizing-xs) solid var(--gray-dark);
margin: var(--margin-vertical-base-horizontal-zero);
}
:is(h1, h2, h3) svg {
stroke-width: var(--stroke-width-bold);
}
strong,
blockquote {
font-weight: var(--font-weight-bold);
}
em,
blockquote {
font-style: italic;
}
svg {
width: var(--sizing-svg-base);
height: var(--sizing-svg-base);
stroke-width: var(--stroke-width-default);
}
:not(pre) > code {
padding: var(--sizing-xs) var(--sizing-sm);
}
pre {
padding: var(--sizing-lg) var(--sizing-base);
}
:not(pre) > code,
pre {
color: #e1e4e8;
background-color: #161b22;
border: var(--border-gray);
border-radius: var(--border-radius-slight);
}
/* images */
img {
border-radius: var(--border-radius-slight);
&.image-banner {
border: var(--border-default);
height: auto;
width: 100%;
margin: var(--margin-vertical-base-horizontal-zero);
}
}
/* lists */
ul,
ol {
margin: var(--margin-vertical-base-horizontal-zero);
padding-left: var(--spacing-base);
& li:not(:last-child) {
margin-bottom: var(--spacing-lg);
}
}
/* brand + section colors */
.article,
.books,
.brand-github,
.brand-mastodon,
.brand-npm,
.coffee,
.collected,
.concerts,
.country,
.device-tv-old,
.device-watch,
.favorite,
.headphones,
.heart-handshake,
.info-circle,
.link,
.mail,
.mail-plus,
.movies,
.music,
.rss,
.search,
.tattoo,
.tv {
&.article {
--section-color: var(--article);
}
&.books {
--section-color: var(--books);
}
&.brand-github {
--section-color: var(--brand-github);
}
&.brand-mastodon {
--section-color: var(--brand-mastodon);
}
&.brand-npm {
--section-color: var(--brand-npm);
}
&.coffee {
--section-color: var(--brand-buy-me-a-coffee);
}
&.collected {
--section-color: var(--collected);
}
&.concerts {
--section-color: var(--concerts);
}
&.country {
--section-color: var(--country);
}
&.device-tv-old {
--section-color: var(--tv);
}
&.device-watch {
--section-color: var(--now);
}
&.favorite {
--section-color: var(--favorite);
}
&.headphones {
--section-color: var(--music);
}
&.heart-handshake {
--section-color: var(--webrings);
}
&.info-circle {
--section-color: var(--about);
}
&.link {
--section-color: var(--link);
}
&.mail {
--section-color: var(--brand-fastmail);
}
&.mail-plus {
--section-color: var(--newsletter);
}
&.movies,
&.tv {
--section-color: var(--tv);
}
&.music {
--section-color: var(--music);
}
&.rss {
--section-color: var(--brand-rss);
}
&.search {
--section-color: var(--search);
}
&.tattoo {
--section-color: var(--tattoo);
}
color: var(--section-color);
& svg {
stroke: var(--section-color);
}
}
/* links */
a {
color: var(--accent-color);
&.back-link {
margin-bottom: var(--spacing-base);
}
& > img {
border: var(--border-default);
}
& svg {
stroke: var(--accent-color);
}
&:is(:hover, :focus, :active) {
color: var(--accent-color-hover);
transition: color var(--transition-duration-default)
var(--transition-ease-in-out);
& > img {
border: var(--border-default-hover);
transition: border var(--transition-duration-default)
var(--transition-ease-in-out);
}
& svg {
stroke: var(--accent-color-hover);
}
}
}
:is(h1, h2, h3, a, p, span, th, td, .post-meta, .media-title):has(svg) {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
/* headers */
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
line-height: var(--line-height-md);
margin: var(--margin-vertical-base-horizontal-zero);
}
h1 {
font-size: var(--font-size-2xl);
}
h2 {
font-size: var(--font-size-xl);
&.page-title {
margin-top: 0;
}
}
h3 {
font-size: var(--font-size-base);
}
@media screen and (min-width: 768px) {
h1 {
font-size: var(--font-size-3xl);
}
h2 {
font-size: var(--font-size-2xl);
}
h3 {
font-size: var(--font-size-xl);
}
}
/* dividers */
hr {
color: var(--gray-light);
margin: var(--margin-vertical-base-horizontal-zero);
}
/* articles */
article {
margin-bottom: var(--spacing-base);
&:not([class], :last-of-type) {
border-bottom: var(--border-gray);
}
&.intro {
border-bottom: var(--border-gray);
& p {
margin-top: 0;
}
}
& h3 {
margin-top: 0;
}
& .post-meta {
& svg {
stroke: var(--gray-dark);
width: var(--sizing-svg-sm);
height: var(--sizing-svg-sm);
}
& time {
color: var(--gray-dark);
font-size: var(--font-size-sm);
}
}
}
.footnotes {
border-top: var(--border-gray);
& ol li p {
display: inline;
}
#footnote-label {
display: none;
}
}
/* tables */
table {
display: block;
border: var(--border-gray);
border-radius: var(--border-radius-slight);
overflow-x: scroll;
white-space: nowrap;
caption-side: bottom;
overscroll-behavior: none;
margin: var(--margin-vertical-base-horizontal-zero);
}
table,
th,
td {
border-collapse: collapse;
}
:is(th, td):not(:first-child, :last-child) {
border-right: var(--border-gray);
}
th,
tr:not(:last-child) {
border-bottom: var(--border-gray);
}
th,
td {
padding: var(--spacing-sm);
word-break: break-word;
&:first-child {
position: sticky;
left: 0;
max-width: 200px;
border-inline-end: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::after {
content: "";
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
width: 1px;
height: 100%;
background: var(--gray-light);
}
}
}
th {
font-weight: var(--font-weight-bold);
background-color: var(--gray-lighter);
text-align: left;
}
td {
min-width: calc(var(--spacing-3xl) * 2);
white-space: nowrap;
overflow: hidden;
&:first-child {
background: var(--background-color);
width: 100%;
}
}
td:first-of-type,
:where(thead, tfoot) th:nth-child(2) {
border-inline-start: none;
}
/* header */
.main-title {
width: 100%;
padding-top: var(--spacing-3xl);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
& h1 {
margin: 0;
padding: 0;
white-space: nowrap;
}
}
/* nav */
.active,
.active svg {
cursor: not-allowed;
color: var(--accent-color-active);
stroke: var(--accent-color-active);
}
/* layout */
.default-wrapper {
padding-top: var(--spacing-2xl);
}
.main-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1 1 0%;
margin: 0 auto;
}
main,
footer {
width: 80%;
@media screen and (min-width: 768px) {
max-width: 768px;
}
}
footer {
margin: var(--sizing-3xl) auto 0;
& nav {
&.social,
&.sub-pages {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
&.social {
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
width: 100%;
& .icon > span,
& .active > span {
display: none;
}
& .active {
display: flex;
}
}
&.sub-pages {
font-size: var(--font-size-sm);
padding-bottom: var(--spacing-3xl);
gap: var(--sizing-sm);
}
}
}
pre code {
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}

131
src/styles/base/reset.css Normal file
View file

@ -0,0 +1,131 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:where([hidden]:not([hidden='until-found'])) {
display: none !important;
}
:where(html) {
font-size: 100%;
-webkit-text-size-adjust: none;
scrollbar-width: thin;
scrollbar-gutter: stable;
tab-size: 2;
}
:where(html:has(dialog:modal[open])) {
overflow: clip;
}
@media (prefers-reduced-motion: no-preference) {
:where(html:focus-within) {
scroll-behavior: smooth;
}
}
:where(body) {
font-size: var(--font-size-base);
line-height: var(--line-height-base);
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
}
:where(button) {
all: unset;
}
:where(input, button, textarea, select) {
font: inherit;
color: inherit;
}
:where(textarea) {
resize: vertical;
resize: block;
}
:where(button, label, select, summary, [role='button'], [role='option']) {
cursor: pointer;
}
:where(:disabled) {
cursor: not-allowed;
}
:where(label:has(> input:disabled), label:has(+ input:disabled)) {
cursor: not-allowed;
}
:where(a) {
color: inherit;
text-underline-offset: var(--spacing-xs);
}
ul {
list-style-type: disc;
}
ol {
list-style-type: number;
}
:where(ul, ol) {
list-style-position: inside;
}
:where(img, svg, video, canvas, audio, iframe, embed, object) {
display: block;
}
:where(p, h1, h2, h3) {
overflow-wrap: break-word;
}
:where(hr) {
border: none;
border-block-start: 1px solid;
border-block-start-color: currentColor;
color: inherit;
block-size: 0;
overflow: visible;
}
:where(dialog, [popover]) {
border: none;
background: none;
color: inherit;
inset: unset;
max-width: unset;
max-height: unset;
}
:where(dialog:not([open], [popover]), [popover]:not(:popover-open)) {
display: none !important;
}
:where(:focus-visible) {
outline: var(--border-default);
outline-offset: 1px;
border-radius: var(--border-radius-slight);
box-shadow: 0 0 0 1px var(--accent-color);
}
:where(:focus-visible, :target) {
scroll-margin-block: 8vh;
}
:where(.visually-hidden:not(:focus-within, :active)) {
clip-path: inset(50%) !important;
height: 1px !important;
width: 1px !important;
overflow: hidden !important;
position: absolute !important;
white-space: nowrap !important;
border: 0 !important;
user-select: none !important;
}

162
src/styles/base/vars.css Normal file
View file

@ -0,0 +1,162 @@
:root {
/* colors */
--blue-100: #a2c4ff;
--blue-200: #6b9eff;
--blue-300: #4a78ff;
--blue-400: #3364ff;
--blue-500: #2553e6;
--blue-600: #1e42c7;
--gray-100: #f9fafb;
--gray-200: #eceef1;
--gray-300: #dfe3e8;
--gray-400: #959eae;
--gray-500: #7f899b;
--gray-600: #626d7f;
--gray-700: #545e71;
--gray-800: #4a5365;
--gray-900: #14161a;
--gray-lighter: light-dark(var(--gray-200), var(--gray-700));
--gray-light: light-dark(var(--gray-300), var(--gray-600));
--gray-medium: var(--gray-400);
--gray-dark: light-dark(var(--gray-800), var(--gray-300));
/* base theme */
--color-lightest: var(--gray-100);
--color-darkest: var(--gray-900);
--text-color: light-dark(var(--color-darkest), var(--color-lightest));
--background-color: light-dark(var(--color-lightest), var(--color-darkest));
--text-color-inverted: light-dark(
var(--color-lightest),
var(--color-darkest)
);
--background-color-inverted: light-dark(
var(--color-darkest),
var(--color-lightest)
);
--accent-color: light-dark(var(--blue-400), var(--blue-200));
--accent-color-hover: light-dark(var(--blue-600), var(--blue-100));
--accent-color-active: light-dark(var(--blue-600), var(--blue-100));
--brand-buy-me-a-coffee: light-dark(#0d0c22, #ffdd00);
--brand-github: light-dark(#333, #f5f5f5);
--brand-fastmail: light-dark(#0067b9, #ffc107);
--brand-mastodon: light-dark(#563acc, #858afa);
--brand-npm: #cb3837;
--brand-rss: #f26522;
--article: light-dark(#007272, #00ffff);
--about: light-dark(#e4513a, #ff967d);
--books: light-dark(#1a7b1a, #6fff6f);
--collected: light-dark(#9a501a, #ffae73);
--concerts: light-dark(#cb426e, #ff82aa);
--country: light-dark(#146a67, #80dcdc);
--error: light-dark(#b81f1f, #ff8b8b);
--favorite: light-dark(#b03c72, #ff9ccd);
--link: light-dark(#7b5cba, #e2b8ff);
--music: light-dark(#3d7099, #76b8cc);
--newsletter: light-dark(#37b0b0, #91fffa);
--now: light-dark(#cc1076, #ff82d5);
--search: light-dark(#6b5e3a, #c0b594);
--tattoo: light-dark(#951b1b, #ff7373);
--tv: light-dark(#cc3600, #ff8f66);
--warning: light-dark(#cc6f00, #ffbf66);
--webrings: light-dark(#b054b0, #ffb3ff);
/* borders */
--border-default: 1px solid var(--accent-color);
--border-default-hover: 1px solid var(--accent-color-hover);
--border-gray: 1px solid var(--gray-light);
/* fonts */
--font-mono: MonoLisa, Menlo, Consolas, Monaco, Liberation Mono,
Lucida Console, ui-monospace, monospace;
/* text */
--font-size-xs: 0.7rem;
--font-size-sm: 0.85rem;
--font-size-base: 1rem;
--font-size-lg: 1.15rem;
--font-size-xl: 1.3rem;
--font-size-2xl: 1.45rem;
--font-size-3xl: 1.6rem;
--font-weight-base: 400;
--font-weight-bold: 700;
--line-height-sm: 1;
--line-height-md: 1.5;
--line-height-base: 2;
/* sizing */
--sizing-xs: 0.25rem;
--sizing-sm: 0.5rem;
--sizing-md: 0.75rem;
--sizing-lg: 1rem;
--sizing-base: 1.5rem;
--sizing-xl: 1.75rem;
--sizing-2xl: 2rem;
--sizing-3xl: 2.25rem;
--sizing-svg-sm: 18px;
--sizing-svg-base: 24px;
/* spacing */
--spacing-xs: var(--sizing-xs);
--spacing-sm: var(--sizing-sm);
--spacing-md: var(--sizing-md);
--spacing-lg: var(--sizing-lg);
--spacing-base: var(--sizing-base);
--spacing-xl: var(--sizing-xl);
--spacing-2xl: var(--sizing-2xl);
--spacing-3xl: var(--sizing-3xl);
--margin-vertical-base-horizontal-zero: var(--spacing-base) 0;
/* radii */
--border-radius-slight: var(--sizing-xs);
--border-radius-full: 9999px;
/* aspect ratios */
--aspect-ratio-square: 1/1;
--aspect-ratio-vertical: 2/3;
--aspect-ratio-banner: 3/2;
/* grid columns */
--grid-columns-one: repeat(1, minmax(0, 1fr));
--grid-columns-two: repeat(2, minmax(0, 1fr));
--grid-columns-three: repeat(3, minmax(0, 1fr));
--grid-columns-four: repeat(4, minmax(0, 1fr));
--grid-columns-six: repeat(6, minmax(0, 1fr));
--grid-poster: var(--grid-columns-two);
--grid-square: var(--grid-columns-two);
--grid-vertical: var(--grid-columns-three);
@media screen and (min-width: 768px) {
--grid-poster: var(--grid-columns-three);
--grid-square: var(--grid-columns-four);
--grid-vertical: var(--grid-columns-six);
}
/* transitions */
--transition-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--transition-duration-default: 300ms;
/* svgs */
--stroke-width-default: 1.4;
--stroke-width-bold: 2;
--inline-margin-bottom: -5px;
/* shadows */
--box-shadow-media: inset 0 -85px 60px -40px var(--color-darkest);
--box-shadow-text-toggle: inset 0 -120px 60px -60px var(--background-color);
--text-shadow-default: rgba(0, 0, 0, 0.7) 0px 0px 10px;
/* modals */
--modal-overlay-background: light-dark(#ffffffbf, #000000bf);
/* input accent color */
accent-color: var(--accent-color);
}

View file

@ -0,0 +1,14 @@
.addon-links {
display: grid;
gap: var(--sizing-base);
grid-template-columns: var(--grid-columns-one);
@media screen and (min-width: 768px) {
grid-template-columns: var(--grid-columns-two);
}
& article {
border-bottom: 0;
margin-bottom: 0;
}
}

View file

@ -0,0 +1,80 @@
.banner {
padding: var(--spacing-md);
margin: var(--margin-vertical-base-horizontal-zero);
border: 1px solid;
border-radius: var(--border-radius-slight);
& a {
display: inline;
}
& span {
display: inline-flex;
vertical-align: middle;
}
& span,
& svg {
height: var(--sizing-svg-sm);
width: var(--sizing-svg-sm);
}
& p {
font-size: var(--font-size-sm);
color: var(--text-color);
margin: 0;
a {
color: var(--text-color);
}
& svg {
display: inline;
vertical-align: middle;
}
}
&.coffee,
&.error,
&.github,
&.mastodon,
&.npm,
&.old-post,
&.rss,
&.warning {
&.coffee {
--banner-accent-color: var(--brand-buy-me-a-coffee);
}
&.error {
--banner-accent-color: var(--error);
}
&.github {
--banner-accent-color: var(--brand-github);
}
&.mastodon {
--banner-accent-color: var(--brand-mastodon);
}
&.npm {
--banner-accent-color: var(--brand-npm);
}
&.old-post {
--banner-accent-color: var(--gray-dark);
}
&.rss {
--banner-accent-color: var(--brand-rss);
}
&.warning {
--banner-accent-color: var(--warning);
}
border-color: var(--banner-accent-color);
& p a:is(:hover, :active, :focus) {
color: var(--banner-accent-color);
}
& svg {
stroke: var(--banner-accent-color);
}
}
}

View file

@ -0,0 +1,28 @@
@import url("./tab-buttons.css");
@import url("./text-toggle.css");
button,
.button {
appearance: none;
border: none;
border: 2px solid var(--accent-color);
border-radius: var(--border-radius-full);
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-base);
white-space: nowrap;
color: var(--text-color-inverted);
background-color: var(--accent-color);
transition: color var(--transition-duration-default)
var(--transition-ease-in-out);
&:not(.active):is(:hover, :active, :focus, :focus-within) {
background-color: var(--accent-color-hover);
border: 2px solid var(--accent-color-hover);
transition: background-color var(--transition-duration-default)
var(--transition-ease-in-out),
border var(--transition-duration-default) var(--transition-ease-in-out),
color var(--transition-duration-default) var(--transition-ease-in-out);
}
}

View file

@ -0,0 +1,69 @@
::placeholder {
color: var(--text-color);
opacity: 0.5;
}
input:not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]),
textarea {
width: 100%;
}
input:not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]),
textarea,
select {
color: var(--text-color);
border-radius: var(--border-radius-slight);
background-color: var(--background-color);
padding: var(--spacing-sm);
border: var(--border-gray);
}
form,
input:not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]),
textarea {
margin-bottom: var(--spacing-base);
}
textarea {
resize: vertical;
}
.search__form {
margin-top: 0;
& .search__form--input::-webkit-search-cancel-button {
cursor: pointer;
}
}
.search__form--type {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
border: none;
@media screen and (max-width: 768px) {
flex-direction: column;
gap: var(--spacing-xs);
}
}
.search__results {
margin: 0 0 var(--spacing-base);
padding: 0;
list-style: none;
display: none;
& li {
margin: var(--spacing-sm) 0;
&:not(:last-child) {
margin-bottom: var(--spacing-base);
border-bottom: var(--border-gray);
}
}
}
.search__load-more {
margin-bottom: var(--spacing-base);
}

View file

@ -0,0 +1,67 @@
.media-grid {
display: grid;
gap: var(--spacing-sm);
& ~ .pagination {
margin-top: var(--spacing-base);
}
&.poster {
grid-template-columns: var(--grid-poster);
& a {
aspect-ratio: var(--aspect-ratio-banner);
}
}
&.square {
grid-template-columns: var(--grid-square);
& a {
aspect-ratio: var(--aspect-ratio-square);
}
}
&.vertical {
grid-template-columns: var(--grid-vertical);
& a {
aspect-ratio: var(--aspect-ratio-vertical);
}
}
&:is(.poster, .square, .vertical) img {
width: 100%;
height: auto;
}
& .item {
position: relative;
}
& .meta-text {
color: var(--color-lightest);
position: absolute;
z-index: 2;
padding: 0 var(--spacing-sm);
bottom: var(--spacing-sm);
& .header,
& .subheader {
color: var(--color-lightest);
font-size: var(--font-size-sm);
line-height: var(--line-height-md);
text-shadow: var(--text-shadow-default);
}
& .header {
font-weight: var(--font-weight-bold);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
line-clamp: 6;
text-overflow: ellipsis;
overflow: hidden;
}
}
}

View file

@ -0,0 +1,136 @@
menu {
& .menu-primary {
position: absolute;
flex-direction: column;
list-style: none;
padding: 0;
top: calc(var(--spacing-3xl) * 1.75);
left: 0;
width: 100%;
z-index: 3;
& > li {
overflow: hidden;
margin: 0;
padding: var(--spacing-sm) 0;
width: 100%;
background: var(--background-color);
& a,
& .active {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
& a {
text-decoration: none;
}
& .active {
cursor: not-allowed;
}
:is(.icon, .active) svg {
display: none;
}
:is(.icon, .active) span {
display: inline;
}
}
}
#menu-toggle {
display: none;
&:checked + .menu-button-container {
& .menu-closed {
display: none;
}
& .menu-open {
display: block;
}
}
&:not(:checked) + .menu-button-container {
& .menu-closed {
display: block;
}
& .menu-open {
display: none;
}
}
& ~ .menu-primary li {
height: 0;
padding: 0;
font-size: var(--font-size-lg);
}
&:checked ~ .menu-primary li {
height: calc(var(--sizing-3xl) * 1.5);
@media (max-width: 767px) {
border-bottom: var(--border-gray);
}
&:first-child {
@media (max-width: 767px) {
border-top: var(--border-gray);
}
}
}
}
.menu-button-container {
display: unset;
& svg {
cursor: pointer;
}
}
@media (min-width: 768px) {
.menu-primary {
display: flex;
flex-direction: row;
margin: 0;
gap: var(--spacing-md);
position: relative;
top: unset;
left: unset;
width: auto;
& > li {
background: none;
& a {
width: var(--sizing-svg-base);
height: var(--sizing-svg-base);
}
:is(.icon, .active) svg {
display: block;
}
:is(.icon, .active) span {
display: none;
}
}
}
#menu-toggle ~ .menu-primary li,
#menu-toggle:checked ~ .menu-primary li {
height: unset;
}
.menu-button-container {
display: none;
}
}
}

View file

@ -0,0 +1,63 @@
.modal-wrapper,
.modal-body {
inset: 0;
width: 100%;
height: 100%;
position: fixed;
}
.modal-wrapper {
background: var(--modal-overlay-background);
z-index: 3;
.modal-body {
background: var(--background-color);
padding: var(--spacing-lg) var(--spacing-base);
overflow-y: auto;
border-radius: var(--border-radius-slight);
h3 {
margin-top: 0;
}
@media (min-width: 768px) {
max-width: 75%;
max-height: 75%;
inset: 12.5%;
border: var(--border-gray);
}
.modal-close {
position: sticky;
top: 0;
left: 100%;
}
}
}
.modal-input {
display: none;
&:checked ~ .modal-wrapper {
display: block;
}
&:not(:checked) ~ .modal-wrapper {
display: none;
}
}
.modal-toggle,
.modal-close {
cursor: pointer;
display: inline-flex;
vertical-align: middle;
svg {
stroke: var(--accent-color);
&:is(:hover, :focus, :active) {
stroke: var(--accent-color-hover);
}
}
}

View file

@ -0,0 +1,103 @@
.music-chart {
margin: var(--margin-vertical-base-horizontal-zero);
& ol {
padding-left: 0;
@media screen and (min-width: 768px) {
list-style-position: outside;
}
}
& .item {
display: flex;
flex-direction: column;
justify-content: start;
align-items: start;
&:not(:last-of-type) {
margin-bottom: var(--spacing-lg);
}
@media screen and (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
& .progress-bar-wrapper {
max-width: 40%;
@media screen and (max-width: 768px) {
margin-top: var(--spacing-sm);
}
}
& img {
width: calc(var(--sizing-3xl) * 1.5);
height: calc(var(--sizing-3xl) * 1.5);
@media screen and (min-width: 768px) {
width: calc(var(--sizing-3xl) * 2);
height: calc(var(--sizing-3xl) * 2);
}
}
& .info {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
@media screen and (min-width: 768px) {
max-width: calc(75% - var(--sizing-lg));
}
}
& .meta {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-md);
@media screen and (min-width: 768px) {
width: calc(75% - var(--sizing-lg));
}
}
& .meta-text {
display: flex;
flex-direction: column;
justify-content: start;
gap: var(--spacing-xs);
@media screen and (min-width: 768px) {
max-width: 85%;
}
}
& .title {
font-weight: var(--font-weight-bold);
}
& .title,
& .subtext,
& time {
line-height: var(--line-height-md);
word-break: break-word;
}
& .subtext,
& time {
font-size: var(--font-size-sm);
}
& time {
margin-top: var(--spacing-sm);
@media screen and (min-width: 768px) {
text-align: right;
white-space: nowrap;
}
}
}
}

View file

@ -0,0 +1,20 @@
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--spacing-base);
& a {
display: flex;
&.disabled svg {
cursor: not-allowed;
stroke: var(--gray-medium);
stroke-width: var(--stroke-width-default);
}
}
& p {
text-align: center;
}
}

View file

@ -0,0 +1,13 @@
.progress-bar-wrapper {
display: flex;
height: var(--sizing-lg);
width: 100%;
background-color: var(--gray-light);
border-radius: var(--border-radius-full);
overflow: hidden;
& .progress-bar {
background-color: var(--accent-color);
border-radius: var(--border-radius-full);
}
}

View file

@ -0,0 +1,38 @@
#tracks-recent,
#tracks-chart,
.tracks-recent,
.tracks-chart {
display: none;
}
#tracks-recent:checked ~ .tracks-recent,
#tracks-chart:checked ~ .tracks-chart {
display: block;
}
input[id="tracks-recent"] ~ .tracks-recent,
input[id="tracks-chart"] ~ .tracks-chart {
margin-top: var(--spacing-base);
}
[for="tracks-recent"] {
margin-right: var(--spacing-lg);
}
#tracks-recent:checked ~ [for="tracks-recent"],
#tracks-chart:checked ~ [for="tracks-chart"] {
cursor: not-allowed;
border-color: var(--accent-color);
background-color: var(--accent-color);
}
#tracks-recent:not(:checked) ~ [for="tracks-recent"],
#tracks-chart:not(:checked) ~ [for="tracks-chart"] {
color: var(--accent-color);
background: transparent;
}
#tracks-recent:not(:checked) ~ [for="tracks-recent"]:is(:hover, :active),
#tracks-chart:not(:checked) ~ [for="tracks-chart"]:is(:hover, :active) {
color: var(--accent-color-hover);
}

View file

@ -0,0 +1,23 @@
[data-toggle-content] {
&.text-toggle-hidden {
position: relative;
height: 500px;
overflow: hidden;
margin: var(--margin-vertical-base-horizontal-zero);
& p:first-of-type {
margin-top: 0;
}
&::after {
position: absolute;
z-index: 1;
content: "";
box-shadow: var(--box-shadow-text-toggle);
width: 100%;
height: 20%;
bottom: 0;
left: 0;
}
}
}

View file

@ -0,0 +1,15 @@
lite-youtube {
aspect-ratio: 16/9;
width: 100%;
overflow: hidden;
border: var(--border-default) !important;
border-radius: var(--border-radius-slight);
margin: var(--margin-vertical-base-horizontal-zero);
max-width: unset !important;
&:hover {
border: var(--border-default-hover);
transition: border var(--transition-duration-default)
var(--transition-ease-in-out);
}
}

34
src/styles/index.css Normal file
View file

@ -0,0 +1,34 @@
@layer reset, defaults, base, page, components, plugins;
/* style resets */
@import url("./base/reset.css") layer(reset);
/* core defaults */
@import url("./base/fonts.css") layer(defaults);
@import url("./base/vars.css") layer(defaults);
/* base styles */
@import url("./base/index.css") layer(base);
/* page styles */
@import url("./pages/about.css") layer(page);
@import url("./pages/books.css") layer(page);
@import url("./pages/contact.css") layer(page);
@import url("./pages/links.css") layer(page);
@import url("./pages/media.css") layer(page);
@import url("./pages/music.css") layer(page);
@import url("./pages/watching.css") layer(page);
@import url("./pages/webrings.css") layer(page);
/* component styles */
@import url("./components/addon-links.css") layer(components);
@import url("./components/banners.css") layer(components);
@import url("./components/buttons.css") layer(components);
@import url("./components/forms.css") layer(components);
@import url("./components/media-grid.css") layer(components);
@import url("./components/menu.css") layer(components);
@import url("./components/modal.css") layer(components);
@import url("./components/music-chart.css") layer(components);
@import url("./components/paginator.css") layer(components);
@import url("./components/progress-bar.css") layer(components);
@import url("./components/youtube-player.css") layer(components);

View file

@ -0,0 +1,24 @@
:root {
--avatar-size: 16rem;
@media screen and (min-width: 768px) {
--avatar-size: 24rem;
}
}
.avatar-wrapper {
display: flex;
justify-content: center;
width: 100%;
& img {
width: var(--avatar-size);
height: var(--avatar-size);
image-rendering: pixelated;
}
}
.about-title {
margin: var(--margin-vertical-base-horizontal-zero);
text-align: center;
}

View file

@ -0,0 +1,87 @@
:is(.book-entry, .book-focus) img {
height: auto;
aspect-ratio: var(--aspect-ratio-vertical);
}
.book-entry {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
&:not(:last-of-type) {
padding-bottom: var(--spacing-base);
border-bottom: var(--border-gray);
}
@media screen and (min-width: 768px) {
flex-direction: row;
gap: var(--spacing-base);
align-items: start;
}
& img {
max-width: calc(var(--sizing-3xl) * 4);
}
& .media-meta {
margin-top: var(--sizing-base);
align-items: center;
@media screen and (min-width: 768px) {
margin-top: 0;
align-items: start;
}
& .description p:last-of-type {
margin-bottom: 0;
}
& .progress-bar-wrapper {
max-width: 75%;
margin-bottom: 0;
@media screen and (min-width: 768px) {
margin-top: 0;
max-width: 50%;
}
}
}
}
.book-focus {
& .book-display {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-base);
margin-bottom: var(--spacing-base);
@media screen and (min-width: 768px) {
flex-direction: row;
align-items: start;
}
& img {
border: var(--border-default);
}
& .media-meta {
width: 100%;
align-items: center;
@media screen and (min-width: 768px) {
width: auto;
align-items: start;
}
& .progress-bar-wrapper {
max-width: 50%;
@media screen and (min-width: 768px) {
max-width: none;
}
}
}
}
}

View file

@ -0,0 +1,36 @@
.contact-wrapper {
display: grid;
grid-template-columns: var(--grid-columns-one);
gap: var(--spacing-base);
@media screen and (min-width: 768px) {
grid-template-columns: var(--grid-columns-two);
}
& .hp,
& label > span {
display: none;
}
& textarea {
height: calc(var(--sizing-3xl) * 5);
}
& .column.description {
& p:first-of-type {
margin-top: 0;
}
ul {
margin-bottom: 0;
}
}
}
.contact-success-wrapper {
text-align: center;
& h2 {
margin: 0;
}
}

View file

@ -0,0 +1,15 @@
.link-grid {
display: grid;
gap: var(--spacing-sm);
grid-template-columns: var(--grid-columns-one);
@media screen and (min-width: 768px) {
grid-template-columns: var(--grid-columns-two);
}
& .link-box {
border: var(--border-gray);
border-radius: var(--border-radius-slight);
padding: var(--spacing-sm) var(--spacing-md);
}
}

View file

@ -0,0 +1,46 @@
.media-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
& .title {
font-size: var(--font-size-xl);
line-height: var(--line-height-md);
}
& .sub-meta {
font-size: var(--font-size-sm);
& svg {
width: var(--sizing-svg-sm);
height: var(--sizing-svg-sm);
}
}
}
a:is(:hover, :active, :focus) .media-overlay::after {
border: var(--border-default-hover);
transition: border-color var(--transition-duration-default)
var(--transition-ease-in-out);
}
.media-overlay::after {
position: absolute;
z-index: 1;
content: "";
top: 0;
left: 0;
width: 100%;
height: 100%;
border: var(--border-default);
box-shadow: var(--box-shadow-media);
border-radius: var(--border-radius-slight);
}
.associated-media {
margin: var(--margin-vertical-base-horizontal-zero);
}
.concerts.media-title {
margin-top: var(--spacing-base);
}

View file

@ -0,0 +1,37 @@
.artist-focus {
& img {
border: var(--border-default);
aspect-ratio: var(--aspect-ratio-square);
width: 100%;
height: auto;
@media screen and (min-width: 768px) {
max-width: calc(var(--sizing-3xl) * 6.75);
}
}
& .artist-display {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-base);
@media screen and (min-width: 768px) {
flex-direction: row;
gap: var(--spacing-md);
}
& .media-meta {
margin-top: var(--spacing-base);
@media screen and (min-width: 768px) {
margin-top: 0;
}
}
}
& table + p {
font-size: var(--font-size-sm);
margin: var(--spacing-base) 0 0;
}
}

View file

@ -0,0 +1,29 @@
.watching.hero {
position: relative;
& img {
aspect-ratio: var(--aspect-ratio-banner);
}
& .meta-text {
color: var(--color-lightest);
position: absolute;
z-index: 2;
left: var(--spacing-sm);
bottom: var(--spacing-sm);
& .header {
font-weight: var(--font-weight-bold);
}
& .subheader {
font-size: var(--font-size-sm);
}
& .header,
& .subheader {
line-height: var(--line-height-md);
text-shadow: var(--text-shadow-default);
}
}
}

View file

@ -0,0 +1,20 @@
.webring-wrapper,
.webring-navigation {
display: flex;
align-items: center;
}
.webring-wrapper {
flex-direction: column;
text-align: center;
margin: var(--margin-vertical-base-horizontal-zero);
& p {
margin: 0;
}
& .webring-navigation {
justify-content: center;
gap: var(--spacing-sm);
}
}

View file

@ -0,0 +1,46 @@
import { parseISO, format, isValid } from "date-fns";
import { toZonedTime } from "date-fns-tz";
import { createEvents } from "ics";
const nowUTC = toZonedTime(new Date(), "UTC");
const formattedDate = format(nowUTC, "yyyyMMdd'T'HHmmss'Z'");
export async function albumReleasesCalendar(albumReleases) {
if (!albumReleases || albumReleases.length === 0) return "";
const events = albumReleases
.map((album) => {
const date = parseISO(album["release_date"]);
if (!isValid(date)) return null;
return {
start: [
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
],
startInputType: "local",
startOutputType: "local",
title: `Release: ${album["artist"]["name"]} - ${album["title"]}`,
description: `Check out this new album release: ${album["url"]}. Read more about ${album["artist"]["name"]} at https://coryd.dev${album["artist"]["url"]}`,
url: album["url"],
uid: `${format(date, "yyyyMMdd")}-${album["artist"]["name"]}-${album["title"]}@coryd.dev`,
timestamp: formattedDate,
};
})
.filter((event) => event !== null);
const { error, value } = createEvents(events, {
calName: "Album releases calendar / coryd.dev",
});
if (error) {
console.error("Error creating events: ", error);
events.forEach((event, index) => {
console.error(`Event ${index}:`, event);
});
return "";
}
return value;
}

View file

@ -0,0 +1 @@
export const CACHE_DURATION = 120 * 1000;

Some files were not shown because too many files have changed in this diff Show more