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

View file

@ -1,6 +1,6 @@
{ {
"name": "coryd.dev", "name": "coryd.dev",
"version": "1.8.0", "version": "2.0.0",
"description": "The source for my personal site. Built using 11ty (and other tools).", "description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module", "type": "module",
"engines": { "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.content AS description,
p.url AS url, p.url AS url,
p.featured AS featured, p.featured AS featured,
p.tags::TEXT[],
NULL AS author, NULL AS author,
NULL AS image, NULL AS image,
NULL AS rating, NULL AS rating,
@ -26,6 +27,7 @@ WITH activity_data AS (
l.description, l.description,
l.link AS url, l.link AS url,
NULL AS featured, NULL AS featured,
l.tags::TEXT[],
l.author, l.author,
NULL AS image, NULL AS image,
NULL AS rating, NULL AS rating,
@ -48,6 +50,7 @@ WITH activity_data AS (
b.description, b.description,
b.url AS url, b.url AS url,
NULL AS featured, NULL AS featured,
b.tags::TEXT[],
NULL AS author, NULL AS author,
b.image, b.image,
b.rating, b.rating,
@ -71,6 +74,7 @@ WITH activity_data AS (
m.description, m.description,
m.url AS url, m.url AS url,
NULL AS featured, NULL AS featured,
m.tags::TEXT[],
NULL AS author, NULL AS author,
m.image, m.image,
m.rating, m.rating,
@ -92,6 +96,7 @@ WITH activity_data AS (
c.concert_notes AS description, c.concert_notes AS description,
NULL AS url, NULL AS url,
NULL AS featured, NULL AS featured,
NULL AS tags,
NULL AS author, NULL AS author,
NULL AS image, NULL AS image,
NULL AS rating, NULL AS rating,

View file

@ -39,6 +39,10 @@ WITH sitemap_data AS (
ss.slug AS url ss.slug AS url
FROM FROM
static_slugs ss 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 SELECT
url 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) function getTablerIcon($iconName, $class = '', $size = 24)
{ {
$icons = [ $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>', '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>', '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>', '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>', '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>'; return $icons[$iconName] ?? '<span class="icon-placeholder">[Missing: ' . htmlspecialchars($iconName) . ']</span>';

View file

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

View file

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

View file

@ -158,7 +158,7 @@
/* transforms */ /* transforms */
--transform-icon-default: rotate(0); --transform-icon-default: rotate(0);
--transform-icon-tilt: rotate(7.5deg); --transform-icon-tilt: rotate(10deg);
@media (prefers-reduced-motion) { @media (prefers-reduced-motion) {
--transform-icon-tilt: var(--transform-icon-default); --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), transition: color var(--transition-duration-default) var(--transition-ease-in-out),
transform 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) { &:is(:hover, :focus, :active) {
color: var(--link-color-hover); color: var(--link-color-hover);
@ -77,6 +72,10 @@ dialog {
left: var(--sizing-full); left: var(--sizing-full);
background: var(--background-color); background: var(--background-color);
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
svg {
--sizing-svg: var(--sizing-svg-base);
}
} }
} }

View file

@ -44,10 +44,6 @@ button svg,
label svg { label svg {
stroke: var(--section-color, var(--accent-color)); stroke: var(--section-color, var(--accent-color));
cursor: pointer; cursor: pointer;
&:is(:hover, :focus, :active) {
stroke: var(--accent-color-hover);
}
} }
summary { 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" }} {{ item.content_date | date:"%B %e, %Y" }}
</time> </time>
• {{ item.label }} • {{ item.label }}
<span class="client-side"> <span class="client-side">
{%- if item.notes -%} {%- if item.notes -%}
{% assign notes = item.notes | prepend: "### Notes\n" | markdown %} {% assign notes = item.notes | prepend: "### Notes\n" | markdown %}
{% render "blocks/dialog.liquid", {% render "blocks/dialog.liquid",
label:"Notes", icon:"info-circle",
content:notes, content:notes,
id:item.content_date id:item.content_date
%} %}
@ -54,6 +54,9 @@
{%- endif -%} {%- endif -%}
{%- endif -%} {%- endif -%}
</h3> </h3>
{% render "blocks/tags.liquid",
18 tags:item.tags
19 %}
</article> </article>
{%- endfor -%} {%- endfor -%}
</article> </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:image" content="<?= htmlspecialchars("{{ globals.cdn_url }}" . ($ogImage ?? '{{ ogImage }}'), ENT_QUOTES, 'UTF-8') ?>" />
<meta property="og:url" content="<?= htmlspecialchars($fullUrl ?? '{{ fullUrl }}', 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') ?>" /> <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" %} {% render "fetchers/movie.php.liquid" %}
{%- when 'show' -%} {%- when 'show' -%}
{% render "fetchers/show.php.liquid" %} {% render "fetchers/show.php.liquid" %}
{%- when 'tags' -%}
{% render "fetchers/tags.php.liquid" %}
{%- when 'blog' -%} {%- when 'blog' -%}
{%- assign pageTitle = post.title -%} {%- assign pageTitle = post.title -%}
{%- assign pageDescription = post.description -%} {%- assign pageDescription = post.description -%}

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,10 @@ schema: movie
height="180" height="180"
/> />
<div class="media-meta"> <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"])): ?> <?php if (!empty($movie["rating"])): ?>
<span><?= htmlspecialchars($movie["rating"]) ?></span> <span><?= htmlspecialchars($movie["rating"]) ?></span>
<?php endif; ?> <?php endif; ?>

View file

@ -22,7 +22,10 @@ schema: show
height="180" height="180"
/> />
<div class="media-meta"> <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"])): ?> <?php if (!empty($show["favorite"])): ?>
<span class="sub-meta favorite">{% tablericon "heart" %} This is one of my favorite shows!</span> <span class="sub-meta favorite">{% tablericon "heart" %} This is one of my favorite shows!</span>
<?php endif; ?> <?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> <article>
<a href="{{ link.link }}" title="{{ link.title | escape }}"><strong>{{ link.title }}</strong></a> <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 %} {% if link.author %} via <a href="{{ link.author.url }}">{{ link.author.name }}</a>{% endif %}
{% render "blocks/tags.liquid",
tags:link.tags
%}
</article> </article>
</div> </div>
{% endfor %} {% endfor %}

View file

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

View file

@ -13,9 +13,10 @@ schema: blog
{{ post.date | date: "%B %e, %Y" }} {{ post.date | date: "%B %e, %Y" }}
</time> </time>
</aside> </aside>
<h3> <h3>{{ post.title }}</h3>
{{ post.title }} {% render "blocks/tags.liquid",
</h3> tags:post.tags
%}
<div> <div>
{% render "blocks/banners/old-post.liquid", {% render "blocks/banners/old-post.liquid",
isOldPost:post.old_post 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" height="480"
/> />
</div> </div>
<div style="text-align:center"> <h2 style="text-align:center">404</h2>
<h2>404</h2> <p style="text-align:center">What kind of idiots do you have working here?</p>
<p>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>
<p><a href="/">Hurry up and skip out on the room service bill!</a></p>
</div>
<script> <script>
const track404 = () => { const track404 = () => {
const referrer = document.referrer || "Unknown referrer"; 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> <h2 class="page-title">Search</h2>
<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>
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> <noscript>
<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>
<mark <p><mark>Type something in and hit enter.</mark></p>
>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> </noscript>
<form class="search__form" action="https://duckduckgo.com" method="get"> <form class="search__form" action="https://duckduckgo.com" method="get">
<input <input