feat(tags): this adds support for post, link, book, show and movie tags with a tag list view and per tag pages

This commit is contained in:
Cory Dransfeldt 2025-04-16 18:59:47 -07:00
parent 3d866262ca
commit 6fdc0b56b9
No known key found for this signature in database
35 changed files with 500 additions and 70 deletions

22
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "coryd.dev",
"version": "1.8.0",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
"version": "1.8.0",
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"html-minifier-terser": "7.2.0",
@ -1066,9 +1066,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001713",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
"version": "1.0.30001714",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz",
"integrity": "sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==",
"dev": true,
"funding": [
{
@ -2986,9 +2986,9 @@
"license": "BSD-3-Clause"
},
"node_modules/morphdom": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.4.tgz",
"integrity": "sha512-ATTbWMgGa+FaMU3FhnFYB6WgulCqwf6opOll4CBzmVDTLvPMmUPrEv8CudmLPK0MESa64+6B89fWOxP3+YIlxQ==",
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.5.tgz",
"integrity": "sha512-z6bfWFMra7kBqDjQGHud1LSXtq5JJC060viEkQFMBX6baIecpkNr2Ywrn2OQfWP3rXiNFQRPoFjD8/TvJcWcDg==",
"dev": true,
"license": "MIT"
},
@ -4682,9 +4682,9 @@
}
},
"node_modules/tr46": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.0.tgz",
"integrity": "sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "coryd.dev",
"version": "1.8.0",
"version": "2.0.0",
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {

View file

@ -0,0 +1,38 @@
CREATE OR REPLACE FUNCTION get_tagged_content(
tag_query TEXT,
page_size INTEGER DEFAULT 20,
page_offset INTEGER DEFAULT 0
)
RETURNS TABLE (
tag TEXT,
title TEXT,
url TEXT,
content_date TIMESTAMP,
author JSON,
rating TEXT,
featured BOOLEAN,
tags TEXT[],
type TEXT,
label TEXT,
total_count BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
t.tag,
t.title,
t.url,
t.content_date,
t.author,
t.rating,
t.featured,
t.tags,
t.type,
t.label,
COUNT(*) OVER() AS total_count
FROM optimized_tagged_content t
WHERE LOWER(TRIM(t.tag)) = LOWER(TRIM(tag_query))
ORDER BY content_date DESC NULLS LAST
LIMIT page_size OFFSET page_offset;
END;
$$ LANGUAGE plpgsql STABLE;

View file

@ -6,6 +6,7 @@ WITH activity_data AS (
p.content AS description,
p.url AS url,
p.featured AS featured,
p.tags::TEXT[],
NULL AS author,
NULL AS image,
NULL AS rating,
@ -26,6 +27,7 @@ WITH activity_data AS (
l.description,
l.link AS url,
NULL AS featured,
l.tags::TEXT[],
l.author,
NULL AS image,
NULL AS rating,
@ -48,6 +50,7 @@ WITH activity_data AS (
b.description,
b.url AS url,
NULL AS featured,
b.tags::TEXT[],
NULL AS author,
b.image,
b.rating,
@ -71,6 +74,7 @@ WITH activity_data AS (
m.description,
m.url AS url,
NULL AS featured,
m.tags::TEXT[],
NULL AS author,
m.image,
m.rating,
@ -92,6 +96,7 @@ WITH activity_data AS (
c.concert_notes AS description,
NULL AS url,
NULL AS featured,
NULL AS tags,
NULL AS author,
NULL AS image,
NULL AS rating,

View file

@ -39,6 +39,10 @@ WITH sitemap_data AS (
ss.slug AS url
FROM
static_slugs ss
UNION ALL
SELECT CONCAT('/tags/', LOWER(REPLACE(tag, ' ', '-'))) AS url
FROM optimized_all_tags
WHERE tag IS NOT NULL AND TRIM(tag) <> ''
)
SELECT
url

View file

@ -0,0 +1,68 @@
CREATE OR REPLACE VIEW optimized_tagged_content AS
SELECT
unnest(p.tags) AS tag,
p.title::TEXT,
p.url::TEXT,
p.date::timestamp WITHOUT TIME ZONE AS content_date,
NULL AS author,
NULL::TEXT AS rating,
p.featured,
p.tags::TEXT[],
'article'::TEXT AS type,
'Post'::TEXT AS label
FROM optimized_posts p
UNION ALL
SELECT
unnest(l.tags) AS tag,
l.title::TEXT,
l.link::TEXT AS url,
l.date::timestamp WITHOUT TIME ZONE AS content_date,
l.author,
NULL::TEXT AS rating,
NULL::BOOLEAN AS featured,
l.tags::TEXT[],
'link'::TEXT AS type,
'Link'::TEXT AS label
FROM optimized_links l
UNION ALL
SELECT
unnest(b.tags) AS tag,
b.title::TEXT,
b.url::TEXT,
b.date_finished::timestamp WITHOUT TIME ZONE AS content_date,
NULL AS author,
b.rating::TEXT,
NULL::BOOLEAN AS featured,
b.tags::TEXT[],
'books'::TEXT AS type,
'Book'::TEXT AS label
FROM optimized_books b
WHERE LOWER(b.status) = 'finished'
UNION ALL
SELECT
unnest(m.tags) AS tag,
m.title::TEXT,
m.url::TEXT,
m.last_watched::timestamp WITHOUT TIME ZONE AS content_date,
NULL AS author,
m.rating::TEXT,
NULL::BOOLEAN AS featured,
m.tags::TEXT[],
'movies'::TEXT AS type,
'Movie'::TEXT AS label
FROM optimized_movies m
WHERE m.last_watched IS NOT NULL
UNION ALL
SELECT
unnest(s.tags) AS tag,
s.title::TEXT,
s.url::TEXT,
s.last_watched_at::timestamp WITHOUT TIME ZONE AS content_date,
NULL AS author,
NULL::TEXT AS rating,
NULL::BOOLEAN AS featured,
s.tags::TEXT[],
'tv'::TEXT AS type,
'Show'::TEXT AS label
FROM optimized_shows s
WHERE s.last_watched_at IS NOT NULL;

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE VIEW optimized_all_tags AS
SELECT
tag,
COUNT(*) AS uses
FROM optimized_tagged_content
GROUP BY tag
ORDER BY tag ASC;

View file

@ -3,11 +3,14 @@
function getTablerIcon($iconName, $class = '', $size = 24)
{
$icons = [
'arrow-left' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M5 12l6 6" /><path d="M5 12l6 -6" /></svg>',
'arrow-right' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></svg>',
'article' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-article"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 8h10" /><path d="M7 12h10" /><path d="M7 16h10" /></svg>',
'books' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-books"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M9 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M5 8h4" /><path d="M9 16h4" /><path d="M13.803 4.56l2.184 -.53c.562 -.135 1.133 .19 1.282 .732l3.695 13.418a1.02 1.02 0 0 1 -.634 1.219l-.133 .041l-2.184 .53c-.562 .135 -1.133 -.19 -1.282 -.732l-3.695 -13.418a1.02 1.02 0 0 1 .634 -1.219l.133 -.041z" /><path d="M14 9l4 -1" /><path d="M16 16l3.923 -.98" /></svg>',
'device-tv-old' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-device-tv-old"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M16 3l-4 4l-4 -4" /><path d="M15 7v13" /><path d="M18 15v.01" /><path d="M18 12v.01" /></svg>',
'headphones' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-headphones"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M15 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M4 15v-3a8 8 0 0 1 16 0v3" /></svg>',
'movie' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-movie"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M8 4l0 16" /><path d="M16 4l0 16" /><path d="M4 8l4 0" /><path d="M4 16l4 0" /><path d="M4 12l16 0" /><path d="M16 8l4 0" /><path d="M16 16l4 0" /></svg>'
'movie' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-movie"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M8 4l0 16" /><path d="M16 4l0 16" /><path d="M4 8l4 0" /><path d="M4 16l4 0" /><path d="M4 12l16 0" /><path d="M16 8l4 0" /><path d="M16 16l4 0" /></svg>',
'star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg>'
];
return $icons[$iconName] ?? '<span class="icon-placeholder">[Missing: ' . htmlspecialchars($iconName) . ']</span>';

View file

@ -1,5 +1,7 @@
<?php
require_once "icons.php";
require_once "media.php";
require_once "paginator.php";
require_once "strings.php";
require_once "tags.php";
?>

View file

@ -0,0 +1,44 @@
<?php
require_once __DIR__ . '/icons.php';
function renderPaginator(array $pagination, int $totalPages): void {
if (!$pagination || $totalPages <= 1) return;
?>
<script type="module" src="/assets/scripts/components/select-pagination.js" defer></script>
<nav aria-label="Pagination" class="pagination">
<?php if (!empty($pagination['href']['previous'])): ?>
<a href="<?= $pagination['href']['previous'] ?>" aria-label="Previous page">
<?= getTablerIcon('arrow-left') ?>
</a>
<?php else: ?>
<span><?= getTablerIcon('arrow-left') ?></span>
<?php endif; ?>
<select-pagination data-base-index="1">
<select class="client-side" aria-label="Page selection">
<?php foreach ($pagination['pages'] as $i): ?>
<option value="<?= $i ?>" <?= ($pagination['pageNumber'] === $i) ? 'selected' : '' ?>>
<?= $i ?> of <?= $totalPages ?>
</option>
<?php endforeach; ?>
</select>
<noscript>
<p>
<span aria-current="page"><?= $pagination['pageNumber'] ?></span> of <?= $totalPages ?>
</p>
</noscript>
</select-pagination>
<?php if (!empty($pagination['href']['next'])): ?>
<a href="<?= $pagination['href']['next'] ?>" aria-label="Next page">
<?= getTablerIcon('arrow-right') ?>
</a>
<?php else: ?>
<span><?= getTablerIcon('arrow-right') ?></span>
<?php endif; ?>
</nav>
<?php
}

12
server/utils/tags.php Normal file
View file

@ -0,0 +1,12 @@
<?php
function renderTags(array $tags): void {
if (empty($tags)) return;
echo '<div class="tags">';
foreach ($tags as $tag) {
$slug = strtolower(trim($tag));
echo '<a href="/tags/' . htmlspecialchars($slug) . '">#' . htmlspecialchars($slug) . '</a>';
}
echo '</div>';
}

View file

@ -8,7 +8,7 @@ class SelectPagination extends HTMLElement {
}
get baseIndex() {
return this.getAttribute('data-base-index') || 0
return parseInt(this.getAttribute('data-base-index') || '0', 10)
}
connectedCallback() {
@ -17,10 +17,11 @@ class SelectPagination extends HTMLElement {
this.attachShadow({ mode: 'open' }).appendChild(document.createElement('slot'))
const uriSegments = window.location.pathname.split('/').filter(Boolean)
let pageNumber = this.extractPageNumber(uriSegments) || 0
let pageNumber = this.extractPageNumber(uriSegments)
if (pageNumber === null) pageNumber = this.baseIndex
this.control = this.querySelector('select')
this.control.value = pageNumber
this.control.value = pageNumber.toString()
this.control.addEventListener('change', (event) => {
pageNumber = parseInt(event.target.value)
const updatedUrlSegments = this.updateUrlSegments(uriSegments, pageNumber)
@ -34,13 +35,16 @@ class SelectPagination extends HTMLElement {
}
updateUrlSegments(segments, pageNumber) {
if (!isNaN(segments[segments.length - 1])) {
const lastIsPage = !isNaN(segments[segments.length - 1])
if (lastIsPage) {
segments[segments.length - 1] = pageNumber.toString()
} else {
segments.push(pageNumber.toString())
}
if (pageNumber === parseInt(this.baseIndex)) segments.pop()
if (pageNumber === this.baseIndex) segments.pop()
return segments
}
}

View file

@ -257,10 +257,7 @@ a {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
&:not(:has(+ h1, + h2, + h3)) {
margin-bottom: var(--spacing-base);
}
margin-bottom: var(--spacing-base);
&:is(:hover, :focus, :active) svg {
transform: var(--transform-icon-default);
@ -350,7 +347,13 @@ hr {
time {
color: var(--gray-dark);
font-size: var(--font-size-sm);
line-height: var(--sizing-sm);
}
.tags {
font-size: var(--font-size-sm);
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
article {
@ -366,6 +369,10 @@ article {
h3 {
margin-top: 0;
&:has(+ .tags) {
margin-bottom: 0;
}
}
aside {

View file

@ -158,7 +158,7 @@
/* transforms */
--transform-icon-default: rotate(0);
--transform-icon-tilt: rotate(7.5deg);
--transform-icon-tilt: rotate(10deg);
@media (prefers-reduced-motion) {
--transform-icon-tilt: var(--transform-icon-default);

View file

@ -22,11 +22,6 @@ dialog {
transition: color var(--transition-duration-default) var(--transition-ease-in-out),
transform var(--transition-duration-default) var(--transition-ease-in-out);
svg {
--sizing-svg: var(--sizing-svg-base);
}
&:is(:hover, :focus, :active) {
color: var(--link-color-hover);
@ -77,6 +72,10 @@ dialog {
left: var(--sizing-full);
background: var(--background-color);
border-radius: var(--border-radius-full);
svg {
--sizing-svg: var(--sizing-svg-base);
}
}
}

View file

@ -44,10 +44,6 @@ button svg,
label svg {
stroke: var(--section-color, var(--accent-color));
cursor: pointer;
&:is(:hover, :focus, :active) {
stroke: var(--accent-color-hover);
}
}
summary {

36
src/data/tags.js Normal file
View file

@ -0,0 +1,36 @@
import EleventyFetch from "@11ty/eleventy-fetch";
const { POSTGREST_URL, POSTGREST_API_KEY } = process.env;
export default async function () {
const res = await EleventyFetch(`${POSTGREST_URL}/optimized_all_tags?order=tag.asc`, {
duration: "1h",
type: "json",
fetchOptions: {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${POSTGREST_API_KEY}`,
},
},
});
const tags = await res;
const groupedMap = new Map();
for (const tag of tags) {
const letter = /^[a-zA-Z]/.test(tag.tag) ? tag.tag[0].toUpperCase() : "#";
if (!groupedMap.has(letter)) groupedMap.set(letter, []);
groupedMap.get(letter).push(tag);
}
const grouped = [...groupedMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([letter, tags]) => ({
letter,
tags: tags.sort((a, b) => a.tag.localeCompare(b.tag)),
}));
return grouped;
}

View file

@ -0,0 +1,7 @@
{% if tags %}
<div class="tags">
{%- for tag in tags -%}
<a href="/tags/{{ tag | downcase | url_encode }}">#{{ tag | downcase }}</a>
{%- endfor -%}
</div>
{% endif %}

View file

@ -0,0 +1,109 @@
<?php
require __DIR__ . "/../vendor/autoload.php";
require __DIR__ . "/../server/utils/init.php";
use GuzzleHttp\Client;
use voku\helper\HtmlMin;
$requestUri = $_SERVER["REQUEST_URI"];
$url = trim(parse_url($requestUri, PHP_URL_PATH), "/");
$postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL");
$postgrestApiKey = $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY");
if ($url === "tags") {
readfile("index.html");
exit();
}
if (!preg_match('/^tags\/(.+?)(?:\/(\d+))?$/', $url, $matches)) {
echo file_get_contents(__DIR__ . "/../404/index.html");
exit();
}
if (isset($matches[2]) && (int)$matches[2] === 1) {
header("Location: /tags/{$matches[1]}", true, 301);
exit();
}
$tag = strtolower(urldecode($matches[1]));
if (!preg_match('/^[\p{L}\p{N} _\-]+$/u', $tag)) {
http_response_code(400);
echo "Invalid tag";
exit();
}
$pageParam = isset($matches[2]) ? (int)$matches[2] : 0;
$page = max($pageParam, 1);
$pageSize = 20;
$offset = ($page - 1) * $pageSize;
$cacheKey = "tag_{$tag}_{$page}";
$useRedis = false;
$tagged = null;
try {
if (extension_loaded('redis')) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$useRedis = true;
}
} catch (Exception $e) {
error_log("Redis not available: " . $e->getMessage());
}
if ($useRedis && $redis->exists($cacheKey)) {
$tagged = json_decode($redis->get($cacheKey), true);
} else {
$client = new Client();
try {
$response = $client->post($postgrestUrl . '/rpc/get_tagged_content', [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => "Bearer {$postgrestApiKey}",
],
'json' => [
'tag_query' => $tag,
'page_size' => $pageSize,
'page_offset' => $offset
]
]);
$tagged = json_decode($response->getBody(), true);
if ($useRedis) {
$redis->setex($cacheKey, 3600, json_encode($tagged));
}
} catch (Exception $e) {
error_log($e->getMessage());
echo file_get_contents(__DIR__ . '/../404/index.html');
exit();
}
}
if (!$tagged || count($tagged) === 0) {
echo file_get_contents(__DIR__ . '/../404/index.html');
exit();
}
$totalCount = $tagged[0]['total_count'] ?? 0;
$totalPages = max(ceil($totalCount / $pageSize), 1);
$pagination = [
'pageNumber' => $page,
'pages' => range(1, $totalPages),
'href' => [
'previous' => $page > 1 ? "/tags/{$tag}/" . ($page - 1) : null,
'next' => $page < $totalPages ? "/tags/{$tag}/" . ($page + 1) : null
],
'links' => range(1, $totalPages)
];
$pageTitle = "#" . strtolower(ucfirst($tag)) . "";
$pageDescription = "All content tagged with #" . strtolower(ucfirst($tag)) . ".";
$fullUrl = "https://www.coryd.dev" . $requestUri;
ob_start();
header("Cache-Control: public, max-age=3600");
header("Expires: " . gmdate("D, d M Y H:i:s", time() + 3600) . " GMT");
?>

View file

@ -13,11 +13,11 @@
{{ item.content_date | date:"%B %e, %Y" }}
</time>
• {{ item.label }}
<span class="client-side">
<span class="client-side">
{%- if item.notes -%}
{% assign notes = item.notes | prepend: "### Notes\n" | markdown %}
{% render "blocks/dialog.liquid",
label:"Notes",
{% render "blocks/dialog.liquid",
icon:"info-circle",
content:notes,
id:item.content_date
%}
@ -54,6 +54,9 @@
{%- endif -%}
{%- endif -%}
</h3>
{% render "blocks/tags.liquid",
18 tags:item.tags
19 %}
</article>
{%- endfor -%}
</article>

View file

@ -5,3 +5,11 @@
<meta property="og:image" content="<?= htmlspecialchars("{{ globals.cdn_url }}" . ($ogImage ?? '{{ ogImage }}'), ENT_QUOTES, 'UTF-8') ?>" />
<meta property="og:url" content="<?= htmlspecialchars($fullUrl ?? '{{ fullUrl }}', ENT_QUOTES, 'UTF-8') ?>" />
<link rel="canonical" href="<?= htmlspecialchars($fullUrl ?? '{{ fullUrl }}', ENT_QUOTES, 'UTF-8') ?>" />
<?php if (!empty($pagination)): ?>
<?php if ($pagination['href']['next']): ?>
<link rel="next" href="<?= $pagination['href']['next'] ?>">
<?php endif; ?>
<?php if ($pagination['href']['previous']): ?>
<link rel="prev" href="<?= $pagination['href']['previous'] ?>">
<?php endif; ?>
<?php endif; ?>

View file

@ -29,6 +29,8 @@
{% render "fetchers/movie.php.liquid" %}
{%- when 'show' -%}
{% render "fetchers/show.php.liquid" %}
{%- when 'tags' -%}
{% render "fetchers/tags.php.liquid" %}
{%- when 'blog' -%}
{%- assign pageTitle = post.title -%}
{%- assign pageDescription = post.description -%}

View file

@ -25,9 +25,7 @@ ErrorDocument 404 /404/index.html
ErrorDocument 429 /429/index.html
ErrorDocument 500 /500/index.html
# media routing
# media routing
# dynamic page routing
## artists
RewriteRule ^music/artists/([^/]+)/?$ music/artists/index.php [L]
@ -46,6 +44,9 @@ RewriteRule ^watching/shows/([^/]+)/?$ watching/shows/index.php [L]
## genres
RewriteRule ^music/genres/([^/]+)/?$ music/genres/index.php [L]
## tags
RewriteRule ^tags/([^/]+)(?:/([0-9]+))?/?$ tags/index.php [L]
{% for redirect in redirects -%}
Redirect {{ redirect.status_code | default: "301" }} {{ redirect.source_url }} {{ redirect.destination_url }}
{% endfor -%}

View file

@ -23,7 +23,7 @@ schema: artist
height="200"
/>
<div class="media-meta">
<h2><?= htmlspecialchars($artist["name"]) ?></h2>
<h2 class="page-title"><?= htmlspecialchars($artist["name"]) ?></h2>
<span class="sub-meta country">{% tablericon "map-pin" %} <?= htmlspecialchars(
parseCountryField($artist["country"])
) ?></span>

View file

@ -24,7 +24,10 @@ schema: book
height="307"
/>
<div class="media-meta">
<h2><?= htmlspecialchars($book["title"]) ?></h2>
<h2 class="page-title"><?= htmlspecialchars($book["title"]) ?></h2>
<?php if (!empty($book["tags"])): ?>
<?php renderTags($book["tags"] ?? []); ?>
<?php endif; ?>
<?php if (!empty($book["rating"])): ?>
<span><?= htmlspecialchars($book["rating"]) ?></span>
<?php endif; ?>

View file

@ -4,7 +4,7 @@ type: dynamic
schema: genre
---
<a class="back-link" href="/music" title="Go back to the music index page">{% tablericon "arrow-left" %} Back to music</a>
<h2><?= htmlspecialchars($genre["emoji"]) ?> <?= htmlspecialchars($genre["name"]) ?></h2>
<h2 class="page-title"><?= htmlspecialchars($genre["emoji"]) ?> <?= htmlspecialchars($genre["name"]) ?></h2>
<article class="genre-focus">
<?php $artistCount = count($genre["artists"]); ?>
<?php if ($artistCount > 0): ?>

View file

@ -22,7 +22,10 @@ schema: movie
height="180"
/>
<div class="media-meta">
<h2><?= htmlspecialchars($movie["title"]) ?> (<?= htmlspecialchars($movie["year"]) ?>)</h2>
<h2 class="page-title"><?= htmlspecialchars($movie["title"]) ?> (<?= htmlspecialchars($movie["year"]) ?>)</h2>
<?php if (!empty($movie["tags"])): ?>
<?php renderTags($movie["tags"] ?? []); ?>
<?php endif; ?>
<?php if (!empty($movie["rating"])): ?>
<span><?= htmlspecialchars($movie["rating"]) ?></span>
<?php endif; ?>

View file

@ -22,7 +22,10 @@ schema: show
height="180"
/>
<div class="media-meta">
<h2><?= htmlspecialchars($show["title"]) ?> (<?= htmlspecialchars($show["year"]) ?>)</h2>
<h2 class="page-title"><?= htmlspecialchars($show["title"]) ?> (<?= htmlspecialchars($show["year"]) ?>)</h2>
<?php if (!empty($show["tags"])): ?>
<?php renderTags($show["tags"] ?? []); ?>
<?php endif; ?>
<?php if (!empty($show["favorite"])): ?>
<span class="sub-meta favorite">{% tablericon "heart" %} This is one of my favorite shows!</span>
<?php endif; ?>

View file

@ -0,0 +1,50 @@
---
permalink: /tags/index.php
type: dynamic
schema: tags
---
<a class="back-link" href="/tags" title="Go back to the tags index page">{% tablericon "arrow-left" %} Back to tags</a>
<h2 class="page-title">#<?= htmlspecialchars($tag) ?></h2>
<p><mark><?= number_format($totalCount) ?> item<?= $totalCount === 1 ? '' : 's' ?></mark>&thinsp;items tagged with <mark>#<?= htmlspecialchars($tag) ?></mark>. <a class="search" href="/search">You can search my site as well</a>.</p>
<hr />
<?php foreach ($tagged as $item): ?>
<article class="<?= $item['type'] ?>">
<aside>
<?php if (!empty($item['featured'])): ?>
<?= getTablerIcon('star') ?>
<?php endif; ?>
<time datetime="<?= date('F j, Y', strtotime($item['content_date'])) ?>">
<?= date('F j, Y', strtotime($item['content_date'])) ?>
</time>
• <?= $item['label'] ?>
</aside>
<h3>
<a href="<?= htmlspecialchars($item['url']) ?>"><?= htmlspecialchars($item['title']) ?><?php if (!empty($item['rating'])): ?>&thinsp;(<?= $item['rating'] ?>)<?php endif; ?></a>
<?php if (!empty($item['author'])): ?>
&thinsp;via&thinsp;
<?php if (!empty($item['author']['url'])): ?>
<a href="<?= $item['author']['url'] ?>">
<?= $item['author']['name'] ?>
</a>
<?php else: ?>
<?= $item['author']['name'] ?>
<?php endif; ?>
<?php endif; ?>
</h3>
<?php if (!empty($item["tags"])): ?>
<?php renderTags($item["tags"] ?? []); ?>
<?php endif; ?>
</a>
</article>
<?php endforeach; ?>
<?php renderPaginator($pagination, $totalPages); ?>
<?php
$html = ob_get_clean();
$htmlMin = new HtmlMin();
$htmlMin->doOptimizeAttributes(true);
$htmlMin->doRemoveComments(true);
$htmlMin->doSumUpWhitespace(true);
$htmlMin->doRemoveWhitespaceAroundTags(true);
$htmlMin->doOptimizeViaHtmlDomParser(true);
echo $htmlMin->minify($html);
?>

View file

@ -22,6 +22,9 @@ permalink: "/links/{% if pagination.pageNumber > 0 %}{{ pagination.pageNumber }}
<article>
<a href="{{ link.link }}" title="{{ link.title | escape }}"><strong>{{ link.title }}</strong></a>
{% if link.author %} via <a href="{{ link.author.url }}">{{ link.author.name }}</a>{% endif %}
{% render "blocks/tags.liquid",
tags:link.tags
%}
</article>
</div>
{% endfor %}

View file

@ -26,6 +26,9 @@ permalink: "/posts/{% if pagination.pageNumber > 0 %}{{ pagination.pageNumber }}
<h3>
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
{% render "blocks/tags.liquid",
tags:post.tags
%}
<p>{{ post.description | markdown }}</p>
</article>
{% endfor %}

View file

@ -13,9 +13,10 @@ schema: blog
{{ post.date | date: "%B %e, %Y" }}
</time>
</aside>
<h3>
{{ post.title }}
</h3>
<h3>{{ post.title }}</h3>
{% render "blocks/tags.liquid",
tags:post.tags
%}
<div>
{% render "blocks/banners/old-post.liquid",
isOldPost:post.old_post

View file

@ -0,0 +1,26 @@
---
title: Tags
description: All of the tags I've applied to content on this site — posts, links, books, movies and shows.
permalink: /tags/index.html
---
<h2 class="page-title">Tags</h2>
<p>All of the tags I've applied to content on this site — posts, links, books, movies and shows. <a class="search" href="/search">You can search my site as well</a>.</p>
<hr />
<nav aria-label="Tag navigation">
{% for group in tags %}
<a href="#{{ group.letter }}">{{ group.letter }}</a>
{% endfor %}
</nav>
{% for group in tags %}
<section id="{{ group.letter }}">
<h3>{{ group.letter }}</h3>
<ul class="tag-list">
{% for tag in group.tags %}
<li>
<a href="/tags/{{ tag.tag | downcase }}">#{{ tag.tag }}</a>
<span class="count">({{ tag.uses }})</span>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}

View file

@ -25,11 +25,9 @@ excludeFromSitemap: true
height="480"
/>
</div>
<div style="text-align:center">
<h2>404</h2>
<p>What kind of idiots do you have working here?</p>
<p><a href="/">Hurry up and skip out on the room service bill!</a></p>
</div>
<h2 style="text-align:center">404</h2>
<p style="text-align:center">What kind of idiots do you have working here?</p>
<p style="text-align:center"><a href="/">Hurry up and skip out on the room service bill!</a></p>
<script>
const track404 = () => {
const referrer = document.referrer || "Unknown referrer";

View file

@ -5,25 +5,10 @@ description: Search through posts and other content on my site.
---
<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>
<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). <a href="/tags">You can also browse my tags list</a>.</p>
<noscript>
<p>
<mark
>If you're seeing this it means that you've (quite likely) disabled
JavaScript (that's a totally valid choice!).</mark>
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>
<mark>Type something in and hit enter.</mark>
</p>
<p><mark>If you're seeing this it means that you've (quite likely) disabled JavaScript (that's a totally valid choice!).</mark> 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><mark>Type something in and hit enter.</mark></p>
</noscript>
<form class="search__form" action="https://duckduckgo.com" method="get">
<input