initial commit

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

6
.env Normal file
View file

@ -0,0 +1,6 @@
ACCOUNT_ID_PLEX=
SUPABASE_URL=
SUPABASE_KEY=
CF_ACCOUNT_ID=
CF_ZONE_ID=
RSS_TO_MASTODON_KV_NAMESPACE_ID=

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# build output
dist/
# workers
wrangler.toml
.wrangler
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env.local
# macOS-specific files
.DS_Store
# VS Code files
.vscode/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Cory Dransfeldt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

9
README.md Normal file
View file

@ -0,0 +1,9 @@
# coryd.dev
Hi! I'm Cory. 👋🏻
This is the code for my personal website and portfolio. Built using [11ty](https://www.11ty.dev) and [other tools](https://coryd.dev/colophon).
[![Follow @cory@follow.coryd.dev on Mastodon](https://cdn.coryd.dev/aec0ea1d-5234-40b3-b7c4-9705fd9cd0e4.png?class=w200)](https://follow.coryd.dev/@cory) [![Buy me a Coffee](https://cdn.coryd.dev/f5b2cae1-331a-4ea0-9962-58ac429cac4a.png?class=w200)](https://www.buymeacoffee.com/cory) [![Made with 11ty](https://cdn.coryd.dev/03c57479-cfa6-423f-8f60-87f3bbef156c.png?class=w200)](https://eleventy.dev)
[Music](https://coryd.dev/music) / [Watching](https://coryd.dev/watching) / [Books](https://coryd.dev/books) / [Now](https://coryd.dev/now)

67
_headers Normal file
View file

@ -0,0 +1,67 @@
/feeds/album-releases
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/feeds/album-releases.json
Content-Type: application/json
/feeds/all
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/feeds/all.json
Content-Type: application/json
/feeds/books
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/feeds/books.json
Content-Type: application/json
/feeds/links
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/feeds/links.json
Content-Type: application/json
/feeds/posts
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/feeds/posts.json
Content-Type: application/json
/feeds/movies
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/feeds/movies.json
Content-Type: application/json
/feeds/syndication
Content-Type: application/xml; charset=utf-8
x-content-type-options: nosniff
/api/search
Content-Type: application/json
/.well-known/webfinger
Content-Type: application/jrd+json; charset=utf-8
/blogroll.opml
Content-Disposition: attachment; filename=cory-dransfeldt-blogroll.opml
/music/releases.ics
Content-Type: text/calendar
Cache-Control: public, max-age=0, must-revalidate
Access-Control-Allow-Origin: *
Content-Disposition: inline; filename="releases.ics"
/*
Content-Security-Policy: upgrade-insecure-requests; block-all-mixed-content;
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin, no-referrer-when-downgrade
Permissions-Policy: autoplay=(), camera=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=()

72
_redirects Normal file
View file

@ -0,0 +1,72 @@
# 404s
/now.html /now 301
/contact.html /contact 301
# feeds
/feed.xml /feeds/posts 301
/follow.xml /feeds/all 301
/feeds/posts.xml /feeds/posts 301
/feeds/links.xml /feeds/links 301
/feeds/books.xml /feeds/books 301
/feeds/movies.xml /feeds/movies 301
/feeds/all.xml /feeds/all 301
/feeds/posts/ /feeds/posts 301
/feeds/links/ /feeds/links 301
/feeds/books/ /feeds/books 301
/feeds/movies/ /feeds/movies 301
/feeds/all/ /feeds/all 301
# general
/articles/ /posts/ 301
/tags /search 301
# blog posts
/blog/fixing-safari-icloud-syncing /posts/2022/fixing-safari-icloud-syncing/ 301
/blog/migrating-to-fastmail /posts/2022/migrating-to-fastmail/ 301
/blog/client-side-webmentions-in-nextjs /posts/2023/client-side-webmentions-in-nextjs/ 301
/blog/apple-centric-digital-privacy-tools /posts/2022/apple-centric-digital-privacy-tools/ 301
/blog/automating-rss-syndication-with-nextjs-github /posts/2023/automating-rss-syndication-with-nextjs-github/ 301
/blog/apple-music-a-tale-of-woe /posts/2021/apple-music-a-tale-of-woe/ 301
/blog/adding-client-side-rendered-webmentions-to-my-blog /posts/2023/client-side-webmentions-in-nextjs/ 301
/blog/automating-email-cleanup-in-gmail /posts/2022/automating-email-cleanup-in-gmail/ 301
/blog/fastmail-handling-inbound-email-with-regex-filters-now-with-chatgpt /posts/2023/fastmail-handling-inbound-email-with-regex-filters-now-with-chatgpt/ 301
/blog/simple-api-fetch-hooks-with-swr /posts/2022/simple-api-fetch-hooks-with-swr/ 301
/2023/02/automatingandprobablyoverengineeringmy-nowpage /posts/2023/automating-and-overengineering-my-now-page/ 301
/2023/01/workflows-handling-inbound-email-on-fastmail-with-regular-expressions /posts/2023/fastmail-handling-inbound-email-with-regex-filters-now-with-chatgpt/ 301
/posts/2023/i-block-ads/null /posts/2023/i-block-ads/ 301
/posts/2023/i-dont-want-streaming-music/null /posts/2023/i-dont-want-streaming-music/ 301
/posts/2022/migrating-to-fastmail/null /posts/2022/migrating-to-fastmail/ 301
/posts/2023/webmentions-in-eleventy/null /posts/2023/webmentions-in-eleventy/ 301
/posts/2023/a-safari-specific-guide-to-making-the-modern-web-suck-less/null /posts/2023/a-safari-specific-guide-to-making-the-modern-web-suck-less/ 301
/posts/2024/dont-be-afraid-to-admit-when-you-dont-know-something/ /posts/2024/dont-be-afraid-to-admin-when-you-dont-know-something/ 301
/posts/2024/data-sharing-should-always-be-opt-in/ /posts/2024/access-to-data-isnt-a-grant-to-exploit-it/ 301
/posts/2023/popular-posts-widget-using-eleventy-plausible/ /posts/2023/building-a-popular-posts-widget-in-eleventy-using-plausible-analytics/ 301
/posts/2023/i-dont-want-streaming-music/ /posts/2023/i-dont-want-streaming-music-i-just-want-to-stream-my-music/ 301
/posts/2021/apple-music-a-tale-of-woe/ /posts/2022/apple-music-a-tale-of-woe/ 301
/posts/2024/weaving-music-in-and-out-of-my-personal-site/ /posts/2024/weaving-music-data-in-and-out-of-my-personal-website/ 301
/posts/2023/fastmail-handling-inbound-email-with-regex-filters-now-with-chatgpt/ /posts/2023/workflows-handling-inbound-email-on-fastmail-with-regular-expressions-now-featuring-chatgpt/ 301
/-want-anything-your-ai-generates/ /posts/2024/i-dont-want-anything-your-ai-generates/ 301
/posts/2023/default-apps-2023/ /posts/2023/my-default-apps-2023-edition/ 301
/posts/2024/dont-be-afraid-to-admin-when-you-dont-know-something/ /posts/2024/dont-be-afraid-to-admin-when-you-dont-know-something/ 301
/posts/2023/displaying-listening-data-from-apple-music-using-musickit/ /posts/2023/displaying-listening-data-from-apple-music-using-musickitjs/ 301
/posts/2024/2024-minimalism-as-self-preservation/ /posts/2024/minimalism-as-self-preservation/ 301
/posts/2023/client-side-webmentions-in-nextjs/ /posts/2023/adding-client-side-webmentions-to-my-nextjs-blog/ 301
/posts/2024/2024-adblocker-required/ /posts/2024/adblocker-required/ 301
/posts/2023/scheduled-eleventy-builds-cron-github-actions/ /posts/2023/scheduled-eleventy-builds-on-vercel-with-cron-triggered-github-actions/ 301
/posts/2024/against-the-commercial-web/ /posts/2024/against-the-commercial-internet/ 301
/posts/2023/i-removed-tailwind-from-my-site/ /posts/2024/i-removed-tailwind-from-my-site/
/posts/2024/handling-images-with-b2-netlify-image-cdn-hazel-mountain-duck/ /posts/2024/handling-images-with-b2-netlifys-image-cdn-hazel-and-mountain-duck/ 301
/posts/2023/my-default-apps-2023-edition/ /uses 301
/posts/2024/link-blogging-using-readwise/ /posts/2024/link-blogging-using-readwise-reader/ 301
/2022/12/automating-email-cleanup-in-gmail /posts/2022/automating-email-cleanup-in-gmail/ 301
/posts/2023/automate-syndicate-content-mastodon-eleventy/ /posts/2023/automate-and-syndicate-content-from-eleventy-to-mastodon/ 301
/posts/2023/road-to-madness-apple-music-charts/ /posts/2023/road-to-madness-charting-apple-music-listening-data/ 301
/posts/2023/semi-automated-hashtags-syndicated-posts/ /posts/2023/semi-automated-hashtags-for-syndicated-posts/ 301
/posts/2023/automating-rss-syndication-with-nextjs-github/ /posts/2023/automating-rss-syndication-and-sharing-with-nextjs-and-github/ 301
/posts/2023/locally-stored-music-and-storage-as-a-meaningful-constraint/ /posts/2023/doppler-locally-stored-music-and-storage-as-a-beneficial-constraint/ 301
/blog/digital-privacy-tools /posts/2021/digital-privacy-tools/ 301
/posts/2023/now-page-update-matter-favorites/ /posts/2023/now-page-update-favorite-articles-from-matter/ 301
/posts/2023/now-playing-eleventy-netlify-edge-functions-emoji/ /posts/2023/displaying-now-playing-data-with-matching-emoji-using-netlify-edge-functions-and-eleventy/ 301
/posts/2014/sublime-text-ctrl-tab-key-bindings/ /posts/2014/sublime-text-3-ctrl-tab-key-bindings/ 301
/posts/2022/simple-api-fetch-hooks-with-swr/ /posts/2022/simple-data-fetching-with-custom-react-hooks-and-swr/ 301
/posts/2023/drying-up-now-page-templates-eleventy/ /posts/2023/drying-up-now-page-templates-and-normalizing-data-in-eleventy/ 301

22
astro.config.mjs Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [react()],
vite: {
resolve: {
alias: {
"@cdransf": "node_modules/@cdransf",
"@components": "/src/components",
"@data": "/src/utils/data",
"@layouts": "/src/layouts",
"@scripts": "/src/scripts",
"@styles": "/src/styles",
"@utils": "/src/utils",
},
},
},
});

6984
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "coryd.dev",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"build:worker": "node scripts/worker-build.mjs $WORKER_NAME",
"deploy:worker": "wrangler deploy --env production --config workers/$npm_config_worker/wrangler.toml"
},
"dependencies": {
"@astrojs/cloudflare": "^11.2.0",
"@astrojs/react": "^3.6.2",
"@tabler/icons-react": "^3.19.0",
"astro": "^4.16.13",
"luxon": "^3.5.0"
},
"devDependencies": {
"@supabase/supabase-js": "^2.45.4",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-prism": "^2.3.0"
}
}

BIN
public/fonts/ml.woff2 Normal file

Binary file not shown.

BIN
public/fonts/mlb.woff2 Normal file

Binary file not shown.

BIN
public/fonts/mlbi.woff2 Normal file

Binary file not shown.

BIN
public/fonts/mli.woff2 Normal file

Binary file not shown.

288
public/scripts/index.js Normal file
View file

@ -0,0 +1,288 @@
window.addEventListener("load", () => {
// menu keyboard controls
(() => {
const menuInput = document.getElementById("menu-toggle");
const menuButtonContainer = document.querySelector(
".menu-button-container"
);
const menuItems = document.querySelectorAll(".menu-primary li");
menuButtonContainer.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
menuInput.checked = !menuInput.checked;
}
});
menuItems.forEach((item) => {
item.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
item.querySelector("a").click();
}
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && menuInput.checked) menuInput.checked = false;
});
})();
// modal keyboard controls and scroll management
(() => {
const modalInputs = document.querySelectorAll(".modal-input");
if (!modalInputs) return;
const toggleBodyScroll = (disableScroll) => {
if (disableScroll) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
};
const checkModals = () => {
let isAnyModalOpen = false;
modalInputs.forEach((modalInput) => {
if (modalInput.checked) isAnyModalOpen = true;
});
toggleBodyScroll(isAnyModalOpen);
};
modalInputs.forEach((modalInput) => {
modalInput.addEventListener("change", checkModals);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
modalInputs.forEach((modalInput) => {
if (modalInput.checked) modalInput.checked = false;
});
toggleBodyScroll(false);
}
});
checkModals();
})();
// text toggle for media pages
(() => {
const button = document.querySelector("[data-toggle-button]");
const content = document.querySelector("[data-toggle-content]");
const text = document.querySelectorAll("[data-toggle-content] p");
const minHeight = 500; // this needs to match the height set on [data-toggle-content].text-toggle-hidden in text-toggle.css
const interiorHeight = Array.from(text).reduce(
(acc, node) => acc + node.scrollHeight,
0
);
if (!button || !content || !text) return;
if (interiorHeight < minHeight) {
content.classList.remove("text-toggle-hidden");
button.style.display = "none";
return;
}
button.addEventListener("click", () => {
const isHidden = content.classList.toggle("text-toggle-hidden");
button.textContent = isHidden ? "Show more" : "Show less";
});
})();
// search logic
(() => {
if (typeof MiniSearch === "undefined") return;
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);
});
})();
// pagination
(() => {
const dropdown = document.querySelector(".pagination select.client-side");
if (dropdown)
dropdown.addEventListener("change", (event) => {
const selectedOption = event.target.options[event.target.selectedIndex];
const selectedHref = selectedOption.getAttribute("data-href");
if (selectedHref) window.location.href = selectedHref;
});
})();
});

View file

@ -0,0 +1,43 @@
CREATE OR REPLACE FUNCTION public.search_optimized_index(search_query text, page_size integer, page_offset integer, types text[])
RETURNS TABLE(
result_id integer,
url text,
title text,
description text,
tags text,
genre_name text,
genre_url text,
type text,
total_plays text,
rank real,
total_count bigint
)
AS $$
BEGIN
RETURN QUERY
SELECT
s.id::integer AS result_id,
s.url,
s.title,
s.description,
array_to_string(s.tags, ', ') AS tags,
s.genre_name,
s.genre_url,
s.type,
s.total_plays,
ts_rank_cd(to_tsvector('english', s.title || ' ' || s.description || array_to_string(s.tags, ' ')), plainto_tsquery('english', search_query)) AS rank,
COUNT(*) OVER() AS total_count
FROM
optimized_search_index s
WHERE(types IS NULL
OR s.type = ANY(types))
AND plainto_tsquery('english', search_query) @@ to_tsvector('english', s.title || ' ' || s.description || array_to_string(s.tags, ' '))
ORDER BY
s.type = 'post' DESC,
s.content_date DESC NULLS LAST,
rank DESC
LIMIT page_size OFFSET page_offset;
END;
$$
LANGUAGE plpgsql;

View file

@ -0,0 +1,26 @@
CREATE OR REPLACE VIEW optimized_links AS
SELECT
l.id,
l.title,
l.date,
l.description,
l.link,
a.mastodon,
a.name,
json_build_object('name', a.name, 'url', a.url, 'mastodon', a.mastodon) AS author,
'link' AS type,
(
SELECT
array_agg(t.name)
FROM
links_tags lt
LEFT JOIN tags t ON lt.tags_id = t.id
WHERE
lt.links_id = l.id) AS tags,
json_build_object('title', CONCAT(l.title, ' via ', a.name), 'url', l.link, 'description', l.description, 'date', l.date) AS feed
FROM
links l
JOIN authors a ON l.author = a.id
ORDER BY
l.date DESC;

View file

@ -0,0 +1,126 @@
CREATE OR REPLACE VIEW optimized_posts AS
SELECT
p.id,
p.date,
p.title,
p.description,
p.content,
p.featured,
p.slug AS url,
p.mastodon_url,
CASE WHEN df.filename_disk IS NOT NULL
AND df.filename_disk != ''
AND df.filename_disk != '/' THEN
CONCAT('/', df.filename_disk)
ELSE
NULL
END AS image,
p.image_alt,
CASE WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date)) > 3 THEN
TRUE
ELSE
FALSE
END AS old_post,
(
SELECT
json_agg(
CASE WHEN pb.collection = 'youtube_player' THEN
json_build_object('type', pb.collection, 'url', yp.url)
WHEN pb.collection = 'github_banner' THEN
json_build_object('type', pb.collection, 'url', gb.url)
WHEN pb.collection = 'npm_banner' THEN
json_build_object('type', pb.collection, 'url', nb.url, 'command', nb.command)
WHEN pb.collection = 'rss_banner' THEN
json_build_object('type', pb.collection, 'url', rb.url, 'text', rb.text)
WHEN pb.collection = 'hero' THEN
json_build_object('type', pb.collection, 'image', CONCAT('/', df_hero.filename_disk), 'alt_text', h.alt_text)
WHEN pb.collection = 'markdown' THEN
json_build_object('type', pb.collection, 'text', md.text)
WHEN pb.collection = 'divider' THEN
json_build_object('type', pb.collection, 'markup', d.markup)
ELSE
json_build_object('type', pb.collection)
END)
FROM
posts_blocks pb
LEFT JOIN youtube_player yp ON pb.collection = 'youtube_player'
AND yp.id = pb.item::integer
LEFT JOIN github_banner gb ON pb.collection = 'github_banner'
AND gb.id = pb.item::integer
LEFT JOIN npm_banner nb ON pb.collection = 'npm_banner'
AND nb.id = pb.item::integer
LEFT JOIN rss_banner rb ON pb.collection = 'rss_banner'
AND rb.id = pb.item::integer
LEFT JOIN hero h ON pb.collection = 'hero'
AND h.id = pb.item::integer
LEFT JOIN directus_files df_hero ON h.image = df_hero.id
LEFT JOIN markdown md ON pb.collection = 'markdown'
AND md.id = pb.item::integer
LEFT JOIN divider d ON pb.collection = 'divider'
AND d.id = pb.item::integer
WHERE
pb.posts_id = p.id) AS blocks,
(
SELECT
array_agg(t.name)
FROM
posts_tags pt
LEFT JOIN tags t ON pt.tags_id = t.id
WHERE
pt.posts_id = p.id) AS tags,
(
SELECT
json_agg(json_build_object('name', g.name, 'url', g.slug))
FROM
posts_genres gp
LEFT JOIN genres g ON gp.genres_id = g.id
WHERE
gp.posts_id = p.id) AS genres,
(
SELECT
json_agg(json_build_object('name', a.name_string, 'url', a.slug, 'country', a.country, 'total_plays', a.total_plays))
FROM
posts_artists pa
LEFT JOIN artists a ON pa.artists_id = a.id
WHERE
pa.posts_id = p.id) AS artists,
(
SELECT
json_agg(json_build_object('title', b.title, 'author', b.author, 'url', b.slug)
ORDER BY b.title)
FROM
posts_books pbk
LEFT JOIN books b ON pbk.books_id = b.id
WHERE
pbk.posts_id = p.id) AS books,
(
SELECT
json_agg(json_build_object('title', m.title, 'year', m.year, 'url', m.slug)
ORDER BY m.year DESC)
FROM
posts_movies pm
LEFT JOIN movies m ON pm.movies_id = m.id
WHERE
pm.posts_id = p.id) AS movies,
(
SELECT
json_agg(json_build_object('title', s.title, 'year', s.year, 'url', s.slug))
FROM
posts_shows ps
LEFT JOIN shows s ON ps.shows_id = s.id
WHERE
ps.posts_id = p.id) AS shows,
json_build_object('title', p.title, 'url', CONCAT('https://coryd.dev', p.slug), 'description', p.content, 'date', p.date, 'image', CASE WHEN df.filename_disk IS NOT NULL
AND df.filename_disk != ''
AND df.filename_disk != '/' THEN
CONCAT('/', df.filename_disk)
ELSE
NULL
END) AS feed
FROM
posts p
LEFT JOIN directus_files df ON p.image = df.id
GROUP BY
p.id,
df.filename_disk;

View file

@ -0,0 +1,79 @@
CREATE OR REPLACE VIEW optimized_all_activity AS
WITH feed_data AS (
SELECT
p.date AS content_date,
p.title,
p.content AS description,
CONCAT('https://coryd.dev', p.url) AS url,
NULL AS image,
NULL AS rating,
p.tags,
json_build_object('title', p.title, 'url', CONCAT('https://coryd.dev', p.url), 'description', p.content, 'date', p.date) AS feed
FROM
optimized_posts p
UNION ALL
SELECT
l.date AS content_date,
l.title,
l.description,
l.link AS url,
NULL AS image,
NULL AS rating,
l.tags,
json_build_object('title', CONCAT(l.title, ' via ', l.name), 'url', l.link, 'description', l.description, 'date', l.date) AS feed
FROM
optimized_links l
UNION ALL
SELECT
b.date_finished AS content_date,
b.title,
b.description,
CONCAT('https://coryd.dev', b.url) AS url,
b.image,
b.rating,
b.tags,
CASE WHEN LOWER(b.status) = 'finished' THEN
json_build_object('title', b.title, 'url', CONCAT('https://coryd.dev', b.url), 'description', CASE WHEN b.review IS NOT NULL THEN
b.review
ELSE
b.description
END, 'image', b.image, 'rating', b.rating, 'date', b.date_finished)
ELSE
NULL
END AS feed
FROM
optimized_books b
UNION ALL
SELECT
m.last_watched AS content_date,
m.title,
m.description,
CONCAT('https://coryd.dev', m.url) AS url,
m.image,
m.rating,
m.tags,
CASE WHEN m.last_watched IS NOT NULL THEN
json_build_object('title', m.title, 'url', CONCAT('https://coryd.dev', m.url), 'description', CASE WHEN m.review IS NOT NULL THEN
m.review
ELSE
m.description
END, 'image', m.backdrop, 'rating', m.rating, 'date', m.last_watched)
ELSE
NULL
END AS feed
FROM
optimized_movies m
)
SELECT
json_agg(feed_data.* ORDER BY feed_data.content_date DESC) AS feed
FROM (
SELECT
*
FROM
feed_data
WHERE
feed IS NOT NULL
ORDER BY
content_date DESC
LIMIT 20) AS feed_data;

View file

@ -0,0 +1,109 @@
CREATE OR REPLACE VIEW optimized_search_index AS
WITH search_data AS (
SELECT
'post' AS type,
CONCAT('📝 ', p.title) AS title,
CONCAT('https://coryd.dev', p.url) AS url,
p.description AS description,
p.tags,
NULL AS genre_name,
NULL AS genre_url,
NULL::text AS total_plays,
p.date AS content_date
FROM
optimized_posts p
UNION ALL
SELECT
'link' AS type,
CONCAT('🔗 ', l.title, ' via ', l.name) AS title,
l.link AS url,
l.description AS description,
l.tags,
NULL AS genre_name,
NULL AS genre_url,
NULL::text AS total_plays,
l.date AS content_date
FROM
optimized_links l
UNION ALL
SELECT
'book' AS type,
CASE WHEN b.rating IS NOT NULL THEN
CONCAT('📖 ', b.title, ' (', b.rating, ')')
ELSE
CONCAT('📖 ', b.title)
END AS title,
CONCAT('https://coryd.dev', b.url) AS url,
b.description AS description,
b.tags,
NULL AS genre_name,
NULL AS genre_url,
NULL::text AS total_plays,
b.date_finished AS content_date
FROM
optimized_books b
WHERE
LOWER(b.status) = 'finished'
UNION ALL
SELECT
'artist' AS type,
CONCAT(COALESCE(ar.emoji, ar.genre_emoji, '🎧'), ' ', ar.name) AS title,
CONCAT('https://coryd.dev', ar.url) AS url,
ar.description AS description,
ARRAY[ar.genre_name] AS tags,
ar.genre_name,
CONCAT('https://coryd.dev', ar.genre_slug) AS genre_url,
to_char(ar.total_plays::numeric, 'FM999,999,999,999') AS total_plays,
NULL AS content_date
FROM
optimized_artists ar
UNION ALL
SELECT
'genre' AS type,
CONCAT(COALESCE(g.emoji, '🎵'), ' ', g.name) AS title,
CONCAT('https://coryd.dev', g.url) AS url,
g.description AS description,
NULL AS tags,
g.name AS genre_name,
CONCAT('https://coryd.dev', g.url) AS genre_url,
NULL::text AS total_plays,
NULL AS content_date
FROM
optimized_genres g
UNION ALL
SELECT
'show' AS type,
CONCAT('📺 ', s.title, ' (', s.year, ')') AS title,
CONCAT('https://coryd.dev', s.url) AS url,
s.description AS description,
s.tags,
NULL AS genre_name,
NULL AS genre_url,
NULL::text AS total_plays,
s.last_watched_at AS content_date
FROM
optimized_shows s
WHERE
s.last_watched_at IS NOT NULL
UNION ALL
SELECT
'movie' AS type,
CONCAT('🎬 ', m.title, ' (', m.rating, ')') AS title,
CONCAT('https://coryd.dev', m.url) AS url,
m.description AS description,
m.tags,
NULL AS genre_name,
NULL AS genre_url,
NULL::text AS total_plays,
m.last_watched AS content_date
FROM
optimized_movies m
WHERE
m.rating IS NOT NULL
)
SELECT
ROW_NUMBER() OVER (ORDER BY url) AS id,
*
FROM
search_data;

View file

@ -0,0 +1,65 @@
CREATE OR REPLACE VIEW optimized_sitemap AS
WITH sitemap_data AS (
SELECT
p.date AS content_date,
p.title,
CONCAT('https://coryd.dev', p.url) AS url,
'monthly' AS changefreq,
0.7 AS priority
FROM
optimized_posts p
UNION ALL
SELECT
b.date_finished AS content_date,
b.title,
CONCAT('https://coryd.dev', b.url) AS url,
'monthly' AS changefreq,
0.5 AS priority
FROM
optimized_books b
UNION ALL
SELECT
m.last_watched AS content_date,
m.title,
CONCAT('https://coryd.dev', m.url) AS url,
'weekly' AS changefreq,
0.6 AS priority
FROM
optimized_movies m
UNION ALL
SELECT
NULL AS content_date,
ar.name AS title,
CONCAT('https://coryd.dev', ar.url) AS url,
'monthly' AS changefreq,
0.5 AS priority
FROM
optimized_artists ar
UNION ALL
SELECT
NULL AS content_date,
g.name AS title,
CONCAT('https://coryd.dev', g.url) AS url,
'yearly' AS changefreq,
0.3 AS priority
FROM
optimized_genres g
UNION ALL
SELECT
s.last_watched_at AS content_date,
s.title,
CONCAT('https://coryd.dev', s.url) AS url,
'weekly' AS changefreq,
0.8 AS priority
FROM
optimized_shows s
)
SELECT
url,
title,
content_date AS lastmod,
changefreq,
priority
FROM
sitemap_data;

View file

@ -0,0 +1,86 @@
CREATE OR REPLACE VIEW optimized_syndication AS
WITH syndication_data AS (
SELECT
p.date AS content_date,
p.title,
p.description,
CONCAT('https://coryd.dev', p.url) AS url,
p.tags,
json_build_object('title', CONCAT('📝 ', p.title, ' ',(
SELECT
array_to_string(array_agg('#' || initcap(replace(t.name, ' ', ''))), ' ')
FROM unnest(p.tags) AS t(name))), 'description', p.description, 'url', CONCAT('https://coryd.dev', p.url), 'date', p.date) AS syndication
FROM
optimized_posts p
UNION ALL
SELECT
l.date AS content_date,
l.title,
l.description,
l.link AS url,
l.tags,
json_build_object('title', CONCAT('🔗 ', l.title, CASE WHEN l.mastodon IS NOT NULL THEN
' via @' || split_part(l.mastodon, '@', 2) || '@' || split_part(split_part(l.mastodon, 'https://', 2), '/', 1)
ELSE
CONCAT(' via ', l.name)
END, ' ',(
SELECT
array_to_string(array_agg('#' || initcap(replace(t.name, ' ', ''))), ' ')
FROM unnest(l.tags) AS t(name))), 'description', l.description, 'url', l.link, 'date', l.date) AS syndication
FROM
optimized_links l
UNION ALL
SELECT
b.date_finished AS content_date,
b.title,
b.description,
CONCAT('https://coryd.dev', b.url) AS url,
b.tags,
CASE WHEN LOWER(b.status) = 'finished' THEN
json_build_object('title', CONCAT('📖 ', b.title, CASE WHEN b.rating IS NOT NULL THEN
' (' || b.rating || ')'
ELSE
''
END, ' ',(
SELECT
array_to_string(array_agg('#' || initcap(replace(t.name, ' ', ''))), ' ')
FROM unnest(b.tags) AS t(name))), 'description', b.description, 'url', CONCAT('https://coryd.dev', b.url), 'date', b.date_finished)
ELSE
NULL
END AS syndication
FROM
optimized_books b
UNION ALL
SELECT
m.last_watched AS content_date,
m.title,
m.description,
CONCAT('https://coryd.dev', m.url) AS url,
m.tags,
CASE WHEN m.last_watched IS NOT NULL THEN
json_build_object('title', CONCAT('🎥 ', m.title, CASE WHEN m.rating IS NOT NULL THEN
' (' || m.rating || ')'
ELSE
''
END, ' ',(
SELECT
array_to_string(array_agg('#' || initcap(replace(t.name, ' ', ''))), ' ')
FROM unnest(m.tags) AS t(name))), 'description', m.description, 'url', CONCAT('https://coryd.dev', m.url), 'date', m.last_watched)
ELSE
NULL
END AS syndication
FROM
optimized_movies m
)
SELECT
json_agg(limited_data.*) AS syndication
FROM (
SELECT
*
FROM
syndication_data
WHERE
syndication IS NOT NULL
ORDER BY
content_date DESC
LIMIT 20) AS limited_data;

View file

@ -0,0 +1,22 @@
CREATE OR REPLACE VIEW optimized_globals AS
SELECT
g.site_name,
g.site_description,
g.intro,
g.author,
g.email,
g.mastodon,
g.url,
g.cdn_url,
g.theme_color,
g.site_type,
g.locale,
g.lang,
g.webfinger_username,
g.webfinger_hostname,
CONCAT('/', df.filename_disk) AS avatar,
CONCAT('/', df2.filename_disk) AS avatar_transparent
FROM
globals g
LEFT JOIN directus_files df ON g.avatar = df.id
LEFT JOIN directus_files df2 ON g.avatar_transparent = df2.id

View file

@ -0,0 +1,14 @@
CREATE OR REPLACE VIEW optimized_navigation AS
SELECT
n.id,
n.menu_location,
n.permalink,
n.icon,
n.title,
n.sort,
p.title AS page_title,
p.permalink AS page_permalink
FROM
navigation n
LEFT JOIN pages p ON n.pages = p.id;

View file

@ -0,0 +1,56 @@
CREATE OR REPLACE VIEW optimized_pages AS
SELECT
p.id,
p.title,
p.permalink,
p.description,
CONCAT('/', df.filename_disk) AS open_graph_image,
p.updated,
(
SELECT
json_agg(
CASE WHEN pb.collection = 'youtube_player' THEN
json_build_object('type', pb.collection, 'url', yp.url)
WHEN pb.collection = 'github_banner' THEN
json_build_object('type', pb.collection, 'url', gb.url)
WHEN pb.collection = 'npm_banner' THEN
json_build_object('type', pb.collection, 'url', nb.url, 'command', nb.command)
WHEN pb.collection = 'rss_banner' THEN
json_build_object('type', pb.collection, 'url', rb.url, 'text', rb.text)
WHEN pb.collection = 'hero' THEN
json_build_object('type', pb.collection, 'image', CONCAT('/', df_hero.filename_disk), 'alt', h.alt_text)
WHEN pb.collection = 'markdown' THEN
json_build_object('type', pb.collection, 'text', md.text)
WHEN pb.collection = 'divider' THEN
json_build_object('type', pb.collection, 'markup', d.markup)
WHEN pb.collection = 'addon_links' THEN
json_build_object('type', pb.collection, 'addon_links', d.title)
ELSE
json_build_object('type', pb.collection)
END ORDER BY pb.sort)
FROM
pages_blocks pb
LEFT JOIN youtube_player yp ON pb.collection = 'youtube_player'
AND yp.id = pb.item::integer
LEFT JOIN github_banner gb ON pb.collection = 'github_banner'
AND gb.id = pb.item::integer
LEFT JOIN npm_banner nb ON pb.collection = 'npm_banner'
AND nb.id = pb.item::integer
LEFT JOIN rss_banner rb ON pb.collection = 'rss_banner'
AND rb.id = pb.item::integer
LEFT JOIN hero h ON pb.collection = 'hero'
AND h.id = pb.item::integer
LEFT JOIN directus_files df_hero ON h.image = df_hero.id
LEFT JOIN markdown md ON pb.collection = 'markdown'
AND md.id = pb.item::integer
LEFT JOIN divider d ON pb.collection = 'divider'
AND d.id = pb.item::integer
WHERE
pb.pages_id = p.id) AS blocks
FROM
pages p
LEFT JOIN directus_files df ON p.open_graph_image = df.id
GROUP BY
p.id,
df.filename_disk;

View file

@ -0,0 +1,95 @@
CREATE OR REPLACE VIEW optimized_books AS
SELECT
b.date_finished,
EXTRACT(YEAR FROM b.date_finished) AS year,
b.author,
b.description,
b.title,
b.progress,
b.read_status AS status,
b.star_rating AS rating,
b.review,
b.slug AS url,
CONCAT('/', df.filename_disk) AS image,
b.favorite,
b.tattoo,
(
SELECT
array_agg(t.name)
FROM
books_tags bt
LEFT JOIN tags t ON bt.tags_id = t.id
WHERE
bt.books_id = b.id) AS tags,
(
SELECT
json_agg(json_build_object('name', a.name_string, 'url', a.slug, 'country', a.country, 'total_plays', a.total_plays))
FROM
books_artists ba
LEFT JOIN artists a ON ba.artists_id = a.id
WHERE
ba.books_id = b.id) AS artists,
(
SELECT
json_agg(json_build_object('title', m.title, 'year', m.year, 'url', m.slug))
FROM
movies_books mb
LEFT JOIN movies m ON mb.movies_id = m.id
WHERE
mb.books_id = b.id) AS movies,
(
SELECT
json_agg(json_build_object('name', g.name, 'url', g.slug))
FROM
genres_books gb
LEFT JOIN genres g ON gb.genres_id = g.id
WHERE
gb.books_id = b.id) AS genres,
(
SELECT
json_agg(json_build_object('title', s.title, 'year', s.year, 'url', s.slug))
FROM
shows_books sb
LEFT JOIN shows s ON sb.shows_id = s.id
WHERE
sb.books_id = b.id) AS shows,
(
SELECT
json_agg(json_build_object('title', p.title, 'date', p.date, 'url', p.slug)
ORDER BY p.date DESC)
FROM
posts_books pb
LEFT JOIN posts p ON pb.posts_id = p.id
WHERE
pb.books_id = b.id) AS posts,
(
SELECT
json_agg(json_build_object('title', rb.title, 'author', rb.author, 'url', rb.slug)
ORDER BY rb.title)
FROM
related_books rbk
LEFT JOIN books rb ON rbk.related_books_id = rb.id
WHERE
rbk.books_id = b.id) AS related_books,
json_build_object('title', CONCAT(b.title, ' by ', b.author), 'image', CONCAT('/', df.filename_disk), 'url', b.slug, 'alt', CONCAT('Book cover from ', b.title, ' by ', b.author), 'subtext', CASE WHEN b.star_rating IS NOT NULL THEN
b.star_rating
ELSE
NULL
END) AS grid,
CASE WHEN LOWER(b.read_status) = 'finished'
AND b.star_rating IS NOT NULL THEN
json_build_object('title', b.title, 'url', CONCAT('https://coryd.dev', b.slug), 'date', b.date_finished, 'description', CASE WHEN b.review IS NOT NULL THEN
b.review
ELSE
b.description
END, 'image', CONCAT('/', df.filename_disk), 'rating', b.star_rating)
ELSE
NULL
END AS feed
FROM
books b
LEFT JOIN directus_files df ON b.art = df.id
GROUP BY
b.id,
df.filename_disk;

View file

@ -0,0 +1,106 @@
CREATE OR REPLACE VIEW optimized_movies AS
SELECT
m.id,
m.last_watched,
m.title,
m.year,
m.collected,
m.plays,
m.favorite,
m.tattoo,
m.star_rating AS rating,
m.description,
m.review,
m.slug AS url,
CONCAT('/', df.filename_disk) AS image,
CONCAT('/', df2.filename_disk) AS backdrop,
json_build_object('title', m.title, 'url', m.slug, 'image', CONCAT('/', df.filename_disk), 'backdrop', CONCAT('/', df2.filename_disk), 'alt', CONCAT('Poster from ', m.title, ' (', m.year, ')'), 'subtext', CASE WHEN m.star_rating IS NOT NULL THEN
CONCAT(m.star_rating, ' (', m.year, ')')
ELSE
CONCAT('(', m.year, ')')
END) AS grid,
(
SELECT
array_agg(t.name)
FROM
movies_tags mt
LEFT JOIN tags t ON mt.tags_id = t.id
WHERE
mt.movies_id = m.id) AS tags,
(
SELECT
json_agg(json_build_object('name', g.name, 'url', g.slug)
ORDER BY g.name ASC)
FROM
genres_movies gm
LEFT JOIN genres g ON gm.genres_id = g.id
WHERE
gm.movies_id = m.id) AS genres,
(
SELECT
json_agg(json_build_object('name', a.name_string, 'url', a.slug, 'country', a.country, 'total_plays', a.total_plays)
ORDER BY a.name_string ASC)
FROM
movies_artists ma
LEFT JOIN artists a ON ma.artists_id = a.id
WHERE
ma.movies_id = m.id) AS artists,
(
SELECT
json_agg(json_build_object('title', b.title, 'author', b.author, 'url', b.slug)
ORDER BY b.title ASC)
FROM
movies_books mb
LEFT JOIN books b ON mb.books_id = b.id
WHERE
mb.movies_id = m.id) AS books,
(
SELECT
json_agg(json_build_object('title', s.title, 'year', s.year, 'url', s.slug)
ORDER BY s.year DESC)
FROM
shows_movies sm
LEFT JOIN shows s ON sm.shows_id = s.id
WHERE
sm.movies_id = m.id) AS shows,
(
SELECT
json_agg(json_build_object('title', p.title, 'date', p.date, 'url', p.slug)
ORDER BY p.date DESC)
FROM
posts_movies pm
LEFT JOIN posts p ON pm.posts_id = p.id
WHERE
pm.movies_id = m.id) AS posts,
(
SELECT
json_agg(json_build_object('title', rm.title, 'year', rm.year, 'url', rm.slug)
ORDER BY rm.year DESC)
FROM
related_movies r
LEFT JOIN movies rm ON r.related_movies_id = rm.id
WHERE
r.movies_id = m.id) AS related_movies,
CASE WHEN m.star_rating IS NOT NULL
AND m.last_watched IS NOT NULL THEN
json_build_object('title', m.title, 'url', CONCAT('https://coryd.dev', m.slug), 'date', m.last_watched, 'description', CASE WHEN m.review IS NOT NULL THEN
m.review
ELSE
m.description
END, 'image', CONCAT('/', df2.filename_disk), 'rating', m.star_rating)
ELSE
NULL
END AS feed
FROM
movies m
LEFT JOIN directus_files df ON m.art = df.id
LEFT JOIN directus_files df2 ON m.backdrop = df2.id
GROUP BY
m.id,
df.filename_disk,
df2.filename_disk
ORDER BY
m.last_watched DESC,
m.favorite DESC,
m.title ASC;

View file

@ -0,0 +1,17 @@
CREATE OR REPLACE VIEW optimized_album_releases AS
SELECT
a.name AS title,
a.release_date,
a.release_link AS url,
a.total_plays,
CONCAT('/', df.filename_disk) AS image,
json_build_object('name', ar.name_string, 'url', ar.slug, 'description', ar.description) AS artist,
EXTRACT(EPOCH FROM a.release_date) AS release_timestamp,
json_build_object('title', a.name, 'image', CONCAT('/', df.filename_disk), 'url', a.release_link, 'alt', CONCAT(a.name, ' by ', ar.name_string), 'subtext', CONCAT(ar.name_string, ' / ', TO_CHAR(a.release_date, 'Mon FMDD, YYYY'))) AS grid
FROM
albums a
LEFT JOIN directus_files df ON a.art = df.id
LEFT JOIN artists ar ON a.artist = ar.id
WHERE
a.release_date IS NOT NULL;

View file

@ -0,0 +1,94 @@
CREATE OR REPLACE VIEW optimized_artists AS
SELECT
ar.name_string AS name,
ar.slug AS url,
ar.tentative,
to_char(ar.total_plays, 'FM999,999,999,999') AS total_plays, -- Format total_plays with commas
ar.country,
ar.description,
ar.favorite,
g.name AS genre_name,
g.slug AS genre_slug,
g.emoji AS genre_emoji,
json_build_object('name', g.name, 'url', g.slug, 'emoji', g.emoji) AS genre,
ar.emoji,
ar.tattoo,
CONCAT('/', df.filename_disk) AS image,
json_build_object('alt', CONCAT(to_char(ar.total_plays, 'FM999,999,999,999'), ' plays of ', ar.name_string), -- Format total_plays in alt text
'subtext', CONCAT(to_char(ar.total_plays, 'FM999,999,999,999'), ' plays') -- Format total_plays in subtext
) AS grid,
(
SELECT
json_agg(json_build_object('name', a.name, 'release_year', a.release_year, 'total_plays', to_char(a.total_plays, 'FM999,999,999,999'), -- Format total_plays here as well
'art', df_album.filename_disk)
ORDER BY a.release_year)
FROM
albums a
LEFT JOIN directus_files df_album ON a.art = df_album.id
WHERE
a.artist = ar.id) AS albums,
(
SELECT
json_agg(json_build_object('id', c.id, 'date', c.date, 'venue_name', v.name, 'venue_name_short', trim(split_part(v.name, ',', 1)), 'venue_latitude', v.latitude, 'venue_longitude', v.longitude, 'notes', c.notes)
ORDER BY c.date DESC)
FROM
concerts c
LEFT JOIN venues v ON c.venue = v.id
WHERE
c.artist = ar.id) AS concerts,
(
SELECT
json_agg(json_build_object('title', b.title, 'author', b.author, 'url', b.slug)
ORDER BY b.date_finished DESC)
FROM
books_artists ba
LEFT JOIN books b ON ba.books_id = b.id
WHERE
ba.artists_id = ar.id) AS books,
(
SELECT
json_agg(json_build_object('title', m.title, 'year', m.year, 'url', m.slug)
ORDER BY m.year DESC)
FROM
movies_artists ma
LEFT JOIN movies m ON ma.movies_id = m.id
WHERE
ma.artists_id = ar.id) AS movies,
(
SELECT
json_agg(json_build_object('title', s.title, 'year', s.year, 'url', s.slug)
ORDER BY s.year DESC)
FROM
shows_artists sa
LEFT JOIN shows s ON sa.shows_id = s.id
WHERE
sa.artists_id = ar.id) AS shows,
(
SELECT
json_agg(json_build_object('title', p.title, 'date', p.date, 'url', p.slug)
ORDER BY p.date DESC)
FROM
posts_artists pa
LEFT JOIN posts p ON pa.posts_id = p.id
WHERE
pa.artists_id = ar.id) AS posts,
(
SELECT
json_agg(json_build_object('name', related_ar.name_string, 'url', related_ar.slug, 'country', related_ar.country, 'total_plays', to_char(related_ar.total_plays, 'FM999,999,999,999'))
ORDER BY related_ar.name_string)
FROM
related_artists ra
LEFT JOIN artists related_ar ON ra.related_artists_id = related_ar.id
WHERE
ra.artists_id = ar.id) AS related_artists
FROM
artists ar
LEFT JOIN directus_files df ON ar.art = df.id
LEFT JOIN genres g ON ar.genres = g.id
GROUP BY
ar.id,
df.filename_disk,
g.name,
g.slug,
g.emoji;

View file

@ -0,0 +1,19 @@
CREATE OR REPLACE VIEW optimized_concerts AS
SELECT
c.id,
c.date,
c.notes,
CASE WHEN c.artist IS NOT NULL THEN
json_build_object('name', a.name_string, 'url', a.slug)
ELSE
json_build_object('name', c.artist_name_string, 'url', NULL)
END AS artist,
json_build_object('name', v.name, 'name_short', trim(split_part(v.name, ',', 1)), 'latitude', v.latitude, 'longitude', v.longitude, 'notes', v.notes) AS venue,
c.notes AS concert_notes
FROM
concerts c
LEFT JOIN artists a ON c.artist = a.id
LEFT JOIN venues v ON c.venue = v.id
ORDER BY
c.date DESC;

View file

@ -0,0 +1,49 @@
CREATE OR REPLACE VIEW optimized_genres AS
SELECT
g.id,
g.name,
g.description,
g.emoji,
to_char(g.total_plays, 'FM999,999,999,999') AS total_plays,
g.wiki_link,
g.slug AS url,
(
SELECT
json_agg(json_build_object('name', a.name_string, 'url', a.slug, 'image', CONCAT('/', df_artist.filename_disk), 'total_plays', to_char(a.total_plays, 'FM999,999,999,999'))
ORDER BY a.total_plays DESC)
FROM
artists a
LEFT JOIN directus_files df_artist ON a.art = df_artist.id
WHERE
a.genres = g.id) AS artists,
(
SELECT
json_agg(json_build_object('title', b.title, 'author', b.author, 'url', b.slug))
FROM
books b
JOIN genres_books gb ON gb.books_id = b.id
WHERE
gb.genres_id = g.id) AS books,
(
SELECT
json_agg(json_build_object('title', m.title, 'year', m.year, 'url', m.slug)
ORDER BY m.year DESC)
FROM
movies m
JOIN genres_movies gm ON gm.movies_id = m.id
WHERE
gm.genres_id = g.id) AS movies,
(
SELECT
json_agg(json_build_object('title', p.title, 'date', p.date, 'url', p.slug)
ORDER BY p.date DESC)
FROM
posts_genres pg
LEFT JOIN posts p ON pg.posts_id = p.id
WHERE
pg.genres_id = g.id) AS posts
FROM
genres g
ORDER BY
g.id ASC;

View file

@ -0,0 +1,28 @@
CREATE OR REPLACE VIEW optimized_listens AS SELECT DISTINCT ON (l.id, l.listened_at, l.track_name, l.artist_name, l.album_name)
l.id,
l.listened_at,
l.track_name,
l.artist_name,
l.album_name,
l.album_key,
CONCAT('/', df_art.filename_disk) AS artist_art,
a.genres AS artist_genres,
g.name AS genre_name,
g.slug AS genre_url,
a.country AS artist_country,
a.slug AS artist_url,
CONCAT('/', df_album.filename_disk) AS album_art
FROM
listens l
LEFT JOIN artists a ON (l.artist_name = a.name_string)
LEFT JOIN albums al ON (l.album_key = al.key)
LEFT JOIN directus_files df_art ON (a.art = df_art.id)
LEFT JOIN directus_files df_album ON (al.art = df_album.id)
LEFT JOIN genres g ON (a.genres = g.id)
ORDER BY
l.id,
l.listened_at,
l.track_name,
l.artist_name,
l.album_name;

View file

@ -0,0 +1,20 @@
CREATE OR REPLACE VIEW month_albums AS
SELECT
ol.album_name,
ol.artist_name,
COUNT(*) AS plays,
ol.album_art,
ol.artist_url,
json_build_object('title', ol.album_name, 'image', ol.album_art, 'url', ol.artist_url, 'alt', CONCAT(ol.album_name, ' by ', ol.artist_name), 'subtext', ol.artist_name) AS grid
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '30 days'
GROUP BY
ol.album_name,
ol.artist_name,
ol.album_art,
ol.artist_url
ORDER BY
plays DESC;

View file

@ -0,0 +1,19 @@
CREATE OR REPLACE VIEW month_artists AS
SELECT
ol.artist_name,
COUNT(*) AS plays,
ol.artist_art,
ol.artist_url,
ARRAY_AGG(DISTINCT ol.genre_name) AS genres,
json_build_object('title', ol.artist_name, 'image', ol.artist_art, 'url', ol.artist_url, 'alt', CONCAT(COUNT(*), ' plays of ', ol.artist_name), 'subtext', CONCAT(COUNT(*), ' plays')) AS grid
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '30 days'
GROUP BY
ol.artist_name,
ol.artist_art,
ol.artist_url
ORDER BY
plays DESC;

View file

@ -0,0 +1,16 @@
CREATE OR REPLACE VIEW month_genres AS
SELECT
ol.genre_name,
ol.genre_url,
COUNT(*) AS plays,
json_build_object('alt', ol.genre_name, 'subtext', CONCAT(COUNT(*), ' plays')) AS grid
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '30 days'
GROUP BY
ol.genre_name,
ol.genre_url
ORDER BY
plays DESC;

View file

@ -0,0 +1,37 @@
CREATE OR REPLACE VIEW month_tracks AS
WITH track_stats AS (
SELECT
ol.track_name,
ol.artist_name,
ol.album_name,
COUNT(*) AS plays,
MAX(ol.listened_at) AS last_listened,
ol.album_art,
ol.artist_url,
MAX(COUNT(*)) OVER () AS most_played
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '30 days'
GROUP BY
ol.track_name,
ol.artist_name,
ol.album_name,
ol.album_art,
ol.artist_url
)
SELECT
track_name,
artist_name,
album_name,
plays,
last_listened,
album_art,
artist_url,
json_build_object('title', track_name, 'artist', artist_name, 'url', artist_url, 'plays', plays, 'alt', CONCAT(track_name, ' by ', artist_name), 'subtext', CONCAT(album_name, ' (', plays, ' plays)'), 'percentage', ROUND((plays::decimal / most_played) * 100, 2)) AS chart
FROM
track_stats
ORDER BY
plays DESC,
last_listened DESC;

View file

@ -0,0 +1,25 @@
CREATE VIEW optimized_latest_listen AS
WITH latest_listen AS (
SELECT
l.track_name,
l.artist_name,
a.emoji AS artist_emoji,
g.emoji AS genre_emoji,
a.slug AS url,
ROW_NUMBER() OVER (ORDER BY l.listened_at DESC) AS row_num
FROM
listens l
JOIN artists a ON l.artist_name = a.name_string
LEFT JOIN genres g ON a.genres = g.id
)
SELECT
track_name,
artist_name,
artist_emoji,
genre_emoji,
url
FROM
latest_listen
WHERE
row_num = 1;

View file

@ -0,0 +1,23 @@
CREATE OR REPLACE VIEW recent_tracks AS
SELECT
ol.id,
ol.listened_at,
ol.track_name,
ol.artist_name,
ol.album_name,
ol.album_key,
ol.artist_art,
ol.artist_genres,
ol.genre_name,
ol.artist_country,
ol.album_art,
ol.artist_url,
ol.genre_url,
json_build_object('title', ol.track_name, 'subtext', ol.artist_name, 'alt', CONCAT(ol.track_name, ' by ', ol.artist_name), 'url', ol.artist_url, 'image', ol.album_art, 'played_at', ol.listened_at) AS chart
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '7 days'
ORDER BY
TO_TIMESTAMP(ol.listened_at) DESC;

View file

@ -0,0 +1,20 @@
CREATE OR REPLACE VIEW week_albums AS
SELECT
ol.album_name,
ol.artist_name,
COUNT(*) AS plays,
ol.album_art,
ol.artist_url,
json_build_object('title', ol.album_name, 'image', ol.album_art, 'url', ol.artist_url, 'alt', CONCAT(ol.album_name, ' by ', ol.artist_name), 'subtext', ol.artist_name) AS grid
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '7 days'
GROUP BY
ol.album_name,
ol.artist_name,
ol.album_art,
ol.artist_url
ORDER BY
plays DESC;

View file

@ -0,0 +1,19 @@
CREATE OR REPLACE VIEW week_artists AS
SELECT
ol.artist_name,
COUNT(*) AS plays,
ol.artist_art,
ol.artist_url,
ARRAY_AGG(DISTINCT ol.genre_name) AS genres,
json_build_object('title', ol.artist_name, 'image', ol.artist_art, 'url', ol.artist_url, 'alt', CONCAT(COUNT(*), ' plays of ', ol.artist_name), 'subtext', CONCAT(COUNT(*), ' plays')) AS grid
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '7 days'
GROUP BY
ol.artist_name,
ol.artist_art,
ol.artist_url
ORDER BY
plays DESC;

View file

@ -0,0 +1,16 @@
CREATE OR REPLACE VIEW week_genres AS
SELECT
ol.genre_name,
ol.genre_url,
COUNT(*) AS plays,
json_build_object('alt', ol.genre_name, 'subtext', CONCAT(COUNT(*), ' plays')) AS grid
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '7 days'
GROUP BY
ol.genre_name,
ol.genre_url
ORDER BY
plays DESC;

View file

@ -0,0 +1,37 @@
CREATE OR REPLACE VIEW week_tracks AS
WITH track_stats AS (
SELECT
ol.track_name,
ol.artist_name,
ol.album_name,
COUNT(*) AS plays,
MAX(ol.listened_at) AS last_listened,
ol.album_art,
ol.artist_url,
MAX(COUNT(*)) OVER () AS most_played
FROM
optimized_listens ol
WHERE
TO_TIMESTAMP(ol.listened_at) >= NOW() - INTERVAL '7 days'
GROUP BY
ol.track_name,
ol.artist_name,
ol.album_name,
ol.album_art,
ol.artist_url
)
SELECT
track_name,
artist_name,
album_name,
plays,
last_listened,
album_art,
artist_url,
json_build_object('title', track_name, 'artist', artist_name, 'url', artist_url, 'plays', plays, 'alt', CONCAT(track_name, ' by ', artist_name), 'subtext', CONCAT(album_name, ' (', plays, ' plays)'), 'percentage', ROUND((plays::decimal / most_played) * 100, 2)) AS chart
FROM
track_stats
ORDER BY
plays DESC,
last_listened DESC;

View file

@ -0,0 +1,94 @@
CREATE OR REPLACE VIEW optimized_shows AS
SELECT
s.id,
s.title,
s.year,
s.collected,
s.favorite,
s.tattoo,
s.description,
s.review,
s.slug AS url,
CONCAT('/', df_art.filename_disk) AS image,
CONCAT('/', df_backdrop.filename_disk) AS backdrop,
json_build_object('title', s.title, 'image', CONCAT('/', df_art.filename_disk), 'backdrop', CONCAT('/', df_backdrop.filename_disk), 'url', s.slug, 'alt', CONCAT('Poster from ', s.title, ' (', s.year, ')'), 'subtext', COALESCE((
SELECT
CASE WHEN e1.last_watched_at >= NOW() - INTERVAL '90 days' THEN
CONCAT('S', e1.season_number, 'E', e1.episode_number)
ELSE
CONCAT('(', s.year::text, ')')
END FROM episodes e1
WHERE
e1.show = s.id ORDER BY e1.last_watched_at DESC, e1.season_number DESC, e1.episode_number DESC LIMIT 1), CONCAT('(', s.year::text, ')'))) AS grid,
json_build_object('title', s.title, 'year', s.year, 'url', s.slug, 'image', CONCAT('/', df_art.filename_disk), 'backdrop', CONCAT('/', df_backdrop.filename_disk), 'formatted_episode', COALESCE((
SELECT
CONCAT('S', e2.season_number, 'E', e2.episode_number)
FROM episodes e2
WHERE
e2.show = s.id ORDER BY e2.season_number DESC, e2.episode_number DESC LIMIT 1)), 'last_watched_at', MAX(e.last_watched_at)) AS episode,
(
SELECT
json_agg(json_build_object('title', m.title, 'year', m.year, 'url', m.slug)
ORDER BY m.year DESC)
FROM
shows_movies sm
LEFT JOIN movies m ON sm.movies_id = m.id
WHERE
sm.shows_id = s.id) AS movies,
(
SELECT
json_agg(json_build_object('title', b.title, 'author', b.author, 'url', b.slug)
ORDER BY b.title)
FROM
shows_books sb
LEFT JOIN books b ON sb.books_id = b.id
WHERE
sb.shows_id = s.id) AS books,
(
SELECT
json_agg(json_build_object('title', p.title, 'date', p.date, 'url', p.slug)
ORDER BY p.date DESC)
FROM
posts_shows ps
LEFT JOIN posts p ON ps.posts_id = p.id
WHERE
ps.shows_id = s.id) AS posts,
(
SELECT
array_agg(t.name)
FROM
shows_tags st
LEFT JOIN tags t ON st.tags_id = t.id
WHERE
st.shows_id = s.id) AS tags,
(
SELECT
json_agg(json_build_object('title', rs.title, 'year', rs.year, 'url', rs.slug)
ORDER BY rs.year DESC)
FROM
related_shows sr
LEFT JOIN shows rs ON sr.related_shows_id = rs.id
WHERE
sr.shows_id = s.id) AS related_shows,
(
SELECT
json_agg(json_build_object('name', a.name_string, 'url', a.slug, 'country', a.country, 'total_plays', a.total_plays)
ORDER BY a.name_string)
FROM
shows_artists sa
LEFT JOIN artists a ON sa.artists_id = a.id
WHERE
sa.shows_id = s.id) AS artists,
MAX(e.last_watched_at) AS last_watched_at
FROM
shows s
LEFT JOIN episodes e ON s.id = e.show
LEFT JOIN directus_files df_art ON s.art = df_art.id
LEFT JOIN directus_files df_backdrop ON s.backdrop = df_backdrop.id
GROUP BY
s.id,
df_art.filename_disk,
df_backdrop.filename_disk
ORDER BY
MAX(e.last_watched_at) DESC;

33
scripts/worker-build.mjs Normal file
View file

@ -0,0 +1,33 @@
import fs from 'fs/promises'
import dotenv from 'dotenv-flow'
dotenv.config()
const workerName = process.argv[2]
if (!workerName) {
console.error('Please specify a worker name.')
process.exit(1)
}
const templatePath = `workers/${workerName}/wrangler.template.toml`
const outputPath = `workers/${workerName}/wrangler.toml`
async function generateToml() {
try {
const template = await fs.readFile(templatePath, 'utf8')
const output = template
.replace(/\${CF_ACCOUNT_ID}/g, process.env.CF_ACCOUNT_ID)
.replace(/\${CF_ZONE_ID}/g, process.env.CF_ZONE_ID)
.replace(/\${RSS_TO_MASTODON_KV_NAMESPACE_ID}/g, process.env.RSS_TO_MASTODON_KV_NAMESPACE_ID)
await fs.writeFile(outputPath, output)
console.log(`Generated wrangler.toml for ${workerName}`)
} catch (error) {
console.error('Error generating wrangler.toml:', error)
process.exit(1)
}
}
generateToml()

View file

@ -0,0 +1,35 @@
---
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 YouTubePlayer from '@components/blocks//YouTubePlayer.astro';
import { md } from '@utils/helpers.js';
const { block } = Astro.props;
const htmlContent = block.type === 'markdown' ? md(block.text) : '';
---
{block.type === 'youtube_player' && (
<YouTubePlayer url={block.url} />
)}
{block.type === 'hero' && (
<Hero image={block.image} alt={block.alt} />
)}
{block.type === 'markdown' && (
<div set:html={htmlContent}></div>
)}
{block.type === 'modal' && (
<Modal content={block.content} />
)}
{block.type === 'associated_media' && (
<AssociatedMedia media={block.media} />
)}
{block.type === 'github_banner' && (
<GitHub url={block.url} />
)}

View file

@ -0,0 +1,34 @@
---
import NavLink from './nav/NavLink.astro';
const { nav, updated } = Astro.props;
---
<footer style={updated ? undefined : 'margin-top: var(--spacing-3xl)'}>
{updated && (
<p class="updated">
<em>This page was last updated on {new Date(updated).toLocaleDateString()}</em>
</p>
)}
<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,17 @@
---
import Menu from './nav/Menu.astro';
const { nav, siteName, url } = 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,43 @@
---
import {
IconArticle,
IconHeadphones,
IconDeviceTvOld,
IconBooks,
IconLink,
IconInfoCircle,
IconSearch,
IconRss,
IconBrandMastodon,
IconMail,
IconBrandGithub,
IconBrandNpm,
IconCoffee,
IconDeviceWatch,
IconHeartHandshake,
} from '@tabler/icons-react';
const { icon } = 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 ? <SelectedIcon size={24} /> : null}

View file

@ -0,0 +1,10 @@
---
import NowPlaying from "@components/blocks/NowPlaying.astro";
const { intro } = Astro.props;
---
<article class="intro">
<div set:html={intro} />
<p class="music"><NowPlaying /></p>
</article>

View file

@ -0,0 +1,43 @@
---
import { fetchMusicData } from '@utils/data/music.js';
import { fetchShows } from '@utils/data/tv.js';
import { fetchMovies } from '@utils/data/movies.js';
import { fetchBooks } from '@utils/data/books.js';
import { fetchLinks } from '@utils/data/links.js';
import { IconActivity } from '@tabler/icons-react';
import Rss from '@components/blocks/banners/Rss.astro';
const music = await fetchMusicData();
const tv = await fetchShows();
const movies = await fetchMovies();
const books = await fetchBooks();
const links = await fetchLinks();
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>
<IconActivity size={24} />
Recent activity
</h2>
<ul>
<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,33 @@
---
import { IconClock, IconStar, IconArrowRight } from '@tabler/icons-react';
import { fetchAllPosts } from '../utils/data/posts.js';
import { md } from '@utils/helpers.js';
const posts = await fetchAllPosts();
---
<h2>
<IconClock size={24} />
Recent posts
</h2>
{posts.slice(0, 5).map(post => (
<article key={post.url}>
<div class="post-meta">
{post.featured && <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)}></p>
</article>
))}
<a class="icon-link" href="/posts">
View all posts <IconArrowRight size={16} />
</a>

View file

@ -0,0 +1,110 @@
---
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}>
<p>
<IconMapper icon={icon} /> {label}
</p>
<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>
(
{new Date(item.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
)
</span>
)}
</li>
))}
</ul>
</section>
);
})
}
</div>

View file

@ -0,0 +1,7 @@
---
const { image, alt } = Astro.props;
---
<div class="hero">
<img src={image} alt={alt} />
</div>

View file

@ -0,0 +1,7 @@
---
const { post } = Astro.props;
---
<article class="mastodon-post">
<p>{post.content}</p>
</article>

View file

@ -0,0 +1,8 @@
---
const { content } = Astro.props;
---
<div class="modal">
<button class="close">Close</button>
<div class="content">{content}</div>
</div>

View file

@ -0,0 +1,24 @@
---
import { fetchNowPlaying } from '../../utils/data/nowPlaying.js';
const isProduction = import.meta.env.MODE === 'production';
const nowPlayingData = await fetchNowPlaying();
---
<span class="loading client-side" id="now-playing-content" set:html={nowPlayingData.content}></span>
<noscript>
<p>{nowPlayingData.content}</p>
</noscript>
{isProduction && (<script type="module">
async function updateNowPlaying() {
const response = await fetch('/api/now-playing');
if (response.ok) {
const data = await response.json();
document.getElementById('now-playing-content').innerHTML = data.content;
}
}
setInterval(updateNowPlaying, 180000);
updateNowPlaying();
</script>)}

View file

@ -0,0 +1,10 @@
---
const { url } = Astro.props;
---
<iframe
width="560"
height="315"
src={url}
allowfullscreen>
</iframe>

View file

@ -0,0 +1,13 @@
---
import { IconCoffee } from "@tabler/icons-react";
---
<div class="banner coffee">
<p>
<a
class="coffee plausible-event-name=Buy+Me+a+Coffee+post+footer"
href="https://buymeacoffee.com/cory"
>
<IconCoffee size={24} /> If you found this post helpful, you can buy me a coffee.
</a>
</p>
</div>

View file

@ -0,0 +1,8 @@
---
import { IconAlertCircle } from '@tabler/icons-react';
const { text } = Astro.props
---
<div class="banner error">
<p><IconAlertCircle size={24} />{ text }</p>
</div>

View file

@ -0,0 +1,9 @@
---
import { IconBrandGithub } from '@tabler/icons-react';
const { url } = Astro.props;
---
<div class="banner github">
<p><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,12 @@
---
import { IconBrandNpm } from '@tabler/icons-react';
const { url, command } = Astro.props;
---
<div class="banner npm">
<p>
<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,14 @@
---
import { IconClockX } from '@tabler/icons-react';
const { isOldPost } = Astro.props;
---
{isOldPost && (
<div class="banner old-post">
<p>
<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,12 @@
---
import { IconRss } from '@tabler/icons-react';
const { url, text } = Astro.props;
---
<div class="banner rss">
<p>
<IconRss size={24} />
<a href={url}>{text}</a>.
</p>
</div>

View file

@ -0,0 +1,12 @@
---
import { IconAlertTriangle } from '@tabler/icons-react';
const { text } = Astro.props;
---
<div class="banner warning">
<p>
<IconAlertTriangle size={24} />
{text}
</p>
</div>

View file

@ -0,0 +1,30 @@
---
import { IconMenu2, IconX } from '@tabler/icons-react';
import NavLink from './NavLink.astro';
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">
<IconMenu2 size={24} />
</div>
<div class="menu-open" aria-hidden="true">
<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,24 @@
---
import IconMapper from '@components/IconMapper.astro';
const { url, title, icon } = Astro.props;
const isHttp = url.startsWith('http');
const isActive = Astro.url.pathname === 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,49 @@
---
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
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"}
>
<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"}
>
<IconArrowRight size={24} />
</a>
</nav>

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

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

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

@ -0,0 +1,96 @@
---
import "@styles/index.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import { fetchNavigation } from "../utils/data/nav.js";
const currentUrl = Astro.url.pathname;
const nav = await fetchNavigation();
const { globals, pageTitle, description, ogImage, fullUrl, schema } =
Astro.props;
const isProduction = import.meta.env.MODE === "production";
---
<!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>{globals.site_name}</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"
/>
<meta
name="description"
content={description || globals?.site_description}
/>
<meta property="og:title" content={pageTitle || globals?.site_name} />
<meta
property="og:description"
content={description || globals?.site_description}
/>
<meta property="og:type" content={schema || "website"} />
<meta property="og:url" content={fullUrl} />
<meta property="og:image" content={`${ogImage}?class=w800`} />
<meta name="theme-color" content={globals?.theme_color} />
<meta name="fediverse:creator" content={globals?.mastodon} />
<link
rel="icon"
type="image/svg+xml"
href={`${globals?.cdn_url}${globals?.avatar_transparent}?class=w200`}
/>
<script defer src="/scripts/index.js"></script>
{
isProduction && (
<>
<script defer data-domain="coryd.dev" src="/js/script.js" />
<script>
window.plausible = window.plausible || function(){" "}
{(window.plausible.q = window.plausible.q || []).push(arguments)}
</script>
</>
)
}
<noscript>
<style>.client-side {display:none}</style>
</noscript>
</head>
<body>
<script>
(() => {
const currentTheme = sessionStorage.getItem("theme");
const metaColorScheme = document.querySelector(
'meta[name="color-scheme"]'
);
const prefersDarkScheme = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const themeToSet =
currentTheme || (prefersDarkScheme ? "dark" : "light");
if (!currentTheme) sessionStorage.setItem("theme", themeToSet);
metaColorScheme?.setAttribute("content", themeToSet);
})();
</script>
<div class="main-wrapper">
<main>
<Header nav={nav} siteName={globals?.site_name} url={currentUrl} />
<div class="default-wrapper">
<slot />
</div>
</main>
<Footer nav={nav} />
</div>
</body>
</html>

View file

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

View file

@ -0,0 +1,64 @@
import fetchSyndication from '../../utils/data/syndication.js';
import { fetchGlobals } from '../../utils/data/globals.js';
export async function GET() {
const globals = await fetchGlobals();
const entries = await fetchSyndication();
if (!entries.length) return new Response('No feed entries found.', { status: 404 });
const title = globals.site_name || 'Syndicated content / Cory Dransfeldt';
const permalink = '/feeds/syndication.xml';
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<atom:link href="${globals.url}${permalink}" rel="self" type="application/rss+xml" />
<title><![CDATA[${title}]]></title>
<description><![CDATA[${globals.site_description || ''}]]></description>
<link>${globals.url}${permalink}</link>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<image>
<title><![CDATA[${title}]]></title>
<link>${globals.url}${permalink}</link>
<url>${globals.cdn_url}${globals.avatar}?class=w200</url>
<width>144</width>
<height>144</height>
</image>
${entries
.slice(0, 20)
.map(
(entry) => `
<item>
<title><![CDATA[${entry.syndication.title}]]></title>
<link>${encodeAmp(entry.syndication.url)}</link>
<pubDate>${new Date(entry.syndication.date).toUTCString()}</pubDate>
<guid isPermaLink="false">${encodeAmp(entry.syndication.url)}</guid>
<description><![CDATA[${escapeHTML(entry.syndication.description)}]]></description>
</item>`
)
.join('')}
</channel>
</rss>`;
return new Response(xml, {
status: 200,
headers: {
'Content-Type': 'application/rss+xml',
},
});
}
function encodeAmp(url) {
return url.replace(/&/g, '&amp;');
}
function escapeHTML(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

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

@ -0,0 +1,29 @@
---
import Layout from '../layouts/Layout.astro';
import Intro from '../components/Intro.astro';
import { fetchGlobals } from '../utils/data/globals';
import RecentActivity from '../components/RecentActivity.astro';
import RecentPosts from '../components/RecentPosts.astro';
const globals = await fetchGlobals();
const schema = 'blog';
const pageTitle = globals.site_name;
const description = 'This is a blog post description';
const ogImage = globals.cdn_url + globals.avatar;
const fullUrl = globals.url + '/blog/my-post';
const themeColor = globals.theme_color;
---
<Layout
globals={globals}
pageTitle={pageTitle}
description={description}
ogImage={ogImage}
fullUrl={fullUrl}
themeColor={themeColor}
schema={schema}
globals={globals}
>
<Intro intro={globals.intro} />
<RecentActivity />
<RecentPosts />
</Layout>

View file

@ -0,0 +1,59 @@
---
import { getCollection } from 'astro:content';
import { IconStar } from '@tabler/icons-react';
import { fetchGlobals } from "@data/globals.js";
import { fetchAllPosts } from "@data/posts.js";
import Layout from "@layouts/Layout.astro";
import Paginator from '@components/nav/Paginator.astro';
import { md } from '@utils/helpers.js';
import { DateTime } from 'luxon';
const globals = await fetchGlobals();
const posts = await fetchAllPosts();
const { page } = Astro.props;
const currentUrl = Astro.url.pathname;
const currentPage = Astro.params.page ? parseInt(Astro.params.page, 10) : 1;
const pageSize = 15;
const totalPosts = posts.length;
const totalPages = Math.ceil(totalPosts / pageSize);
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
const paginatedPosts = posts.slice(start, end);
const pagination = {
currentPage,
totalPages,
hasPrevious: currentPage > 1,
hasNext: currentPage < totalPages,
previousPage: currentPage > 1 ? `/posts/${currentPage - 1}` : null,
nextPage: currentPage < totalPages ? `/posts/${currentPage + 1}` : null,
pages: Array.from({ length: totalPages }, (_, i) => ({
number: i + 1,
href: `/posts/${i + 1}`,
})),
};
---
<Layout
globals={globals}
pageTitle="All posts"
currentUrl={currentUrl}
>
{paginatedPosts.map((post) => (
<article>
<div class="post-meta">
{post.featured && <IconStar size={16} />}
<time datetime={post.date}>
{DateTime.fromISO(post.date).toLocaleString(DateTime.DATE_FULL)}
</time>
</div>
<h3>
<a href={post.slug}>{post.title}</a>
</h3>
<p set:html={md(post.description)}></p>
</article>
))}
<Paginator pagination={pagination} appVersion="1.0.0" />
</Layout>

View file

@ -0,0 +1,102 @@
---
import { IconStar } from "@tabler/icons-react";
import { fetchAllPosts } from "@data/posts.js";
import { fetchGlobals } from "@data/globals.js";
import { md } from '@utils/helpers.js';
import OldPost from "@components/blocks/banners/OldPost.astro";
import BlockRenderer from "@components/BlockRenderer.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import MastodonPost from "@components/blocks/MastodonPost.astro";
import Layout from "@layouts/Layout.astro";
import Coffee from "@components/blocks/banners/Coffee.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 { year, title } = Astro.params;
const globals = await fetchGlobals();
const currentUrl = Astro.url.pathname;
const htmlContent = md(post.content);
---
<Layout
globals={globals}
pageTitle={post.title}
description={post.description}
ogImage={post.open_graph_image}
updated={post.updated}
currentUrl={currentUrl}
>
<article class="standalone">
<div class="post-meta">
{post.featured && <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 &&
post.blocks.map((block) => <BlockRenderer block={block} />)
}
<!-- {post.mastodon_url && <MastodonPost url={post.mastodon_url} />} -->
<AssociatedMedia
artists={post.artists}
books={post.books}
genres={post.genres}
movies={post.movies}
posts={post.posts}
shows={post.shows}
/>
<Coffee />
</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 async function GET() {
try {
const robots = await fetchAllRobots();
const sitemap = `Sitemap: https://coryd.dev/sitemap.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 });
}
}

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;
}

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

@ -0,0 +1,487 @@
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);
}
/* 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:not(.banner p), span, th, td, .post-meta):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-lg);
}
@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);
}
}
}
sup.footnote-ref {
line-height: var(--line-height-xs);
padding: var(--spacing-xs);
}
/* 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%;
}
main,
footer {
width: 80%;
margin: 0 auto;
@media screen and (min-width: 768px) {
max-width: 768px;
}
}
footer {
& .updated {
text-align: center;
font-size: var(--font-size-sm);
margin: var(--spacing-3xl) 0 var(--spacing-lg);
}
& 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);
}
}
}

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,20 @@
.badge-grid {
display: grid;
gap: var(--spacing-md);
grid-template-columns: var(--grid-columns-three);
@media screen and (min-width: 768px) {
grid-template-columns: var(--grid-columns-four);
}
& > a {
display: flex;
justify-content: center;
}
& img {
image-rendering: pixelated;
border: none;
border-radius: 0;
}
}

View file

@ -0,0 +1,63 @@
.banner {
padding: var(--spacing-md);
margin: var(--margin-vertical-base-horizontal-zero);
border: 1px solid;
border-radius: var(--border-radius-slight);
& p {
font-size: var(--font-size-sm);
color: var(--text-color);
margin: 0;
a {
color: var(--text-color);
}
& svg {
display: inline;
vertical-align: middle;
height: var(--sizing-lg);
width: var(--sizing-lg);
}
}
&.coffee,
&.error,
&.github,
&.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);
}
&.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,65 @@
::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(--sizing-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(--sizing-base);
border-bottom: var(--border-gray);
}
}
}

View file

@ -0,0 +1,22 @@
mastodon-post {
width: 100%;
.mastodon-post-wrapper {
& dl,
& dt {
display: flex;
}
& dl {
align-items: center;
& dd {
margin-left: var(--spacing-xs);
&:not(:last-child) {
margin-right: var(--spacing-lg);
}
}
}
}
}

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,25 @@
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--spacing-base);
& button {
background: none;
padding: 0;
}
& a {
display: flex;
&.disabled svg[data-tablericon-name^="arrow-"] {
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-xs);
}
#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 @@
youtube-video {
aspect-ratio: 16/9;
width: 100%;
display: flex;
overflow: hidden;
border: var(--border-default);
border-radius: var(--border-radius-slight);
margin: var(--margin-vertical-base-horizontal-zero);
&:hover {
border: var(--border-default-hover);
transition: border var(--transition-duration-default)
var(--transition-ease-in-out);
}
}

81
src/styles/feed.liquid Normal file
View file

@ -0,0 +1,81 @@
---
permalink: /assets/styles/feed.xsl
---
<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>
<xsl:value-of select="/rss/channel/title" /> / {{ globals.site_name }}
</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="{{ globals.theme_color }}" />
<meta name="fediverse:creator" content="{{ globals.mastodon }}" />
<meta name="generator" content="Eleventy" />
<meta name="robots" content="noai, noimageai" />
<link rel="preload" href="/assets/fonts/ml.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/assets/fonts/mlb.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="stylesheet" href="/assets/styles/index.css?v={% appVersion %}" type="text/css" />
</head>
<body class="feed">
<div class="main-wrapper">
<main>
{% render "header.liquid", globals:globals, page:page, nav:nav %}
<div class="default-wrapper">
<h2 class="page-title">
<xsl:value-of select="/rss/channel/title" />
</h2>
<article class="intro">
<p>
<xsl:value-of select="/rss/channel/description" />
</p>
<p>
<strong class="highlight-text">Subscribe by adding the URL below to your feed reader
of choice.</strong>
</p>
<p>
<pre class="small">
<code><xsl:value-of select="rss/channel/atom:link/@href"/></code>
</pre>
</p>
<p>
<a href="/feeds">View more of the feeds from my site.</a>
</p>
</article>
<section>
<xsl:for-each select="/rss/channel/item">
<article>
<time>Published: <xsl:value-of select="pubDate" /></time>
<h3>
<a>
<xsl:attribute name="href">
<xsl:value-of select="link" />
</xsl:attribute>
<xsl:value-of select="title" />
</a>
</h3>
<xsl:value-of select="description" disable-output-escaping="yes" />
<xsl:if test="enclosure">
<img class="image-banner" src="{enclosure/@url}" alt="{title}" />
</xsl:if>
</article>
</xsl:for-each>
</section>
</div>
</main>
{% render "footer.liquid",
page:page,
nav:nav,
updated:updated,
pageUpdated:page.updated
%}
</div>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

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

@ -0,0 +1,39 @@
@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);
/* plugins */
@import url("./plugins/prism.css") layer(plugins);
/* 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/badge-grid.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/mastodon-post.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,42 @@
.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);
}

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