first commit
@@ -0,0 +1 @@
|
||||
public/* -linguist-detectable
|
||||
@@ -0,0 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
custom: ['https://foggymtndrifter.com/#coffee'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,25 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
.vercel/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 Stel Clementine
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,60 @@
|
||||
# foggymtndrifter.com
|
||||
|
||||
Michael Kinder's personal website and blog.
|
||||
|
||||
This site features a quirky, developer-focused aesthetic with deep customization, local content management, and integrated features for reader interaction.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Terminal Aesthetic**: Heavily themed UI including a terminal-style interface.
|
||||
- **Content Management**:
|
||||
- **Blog**: Technical articles writing in MDX with Expressive Code syntax highlighting.
|
||||
- **CV/Resume**: A dedicated "About" page formatted as a professional CV.
|
||||
- **Legal Pages**: Privacy Policy and Terms of Use (queried from local markdown).
|
||||
- **Interactive Elements**:
|
||||
- **Cookie Consent**: Minimalist, non-intrusive popup.
|
||||
- **Comments**: GitHub-powered comments via [Giscus](https://giscus.app).
|
||||
- **Donations**: Custom "Buy Me a Coffee" modal integrated with Stripe for easy support.
|
||||
- **Tech Stack**:
|
||||
- Built with [Astro v5](https://astro.build)
|
||||
- Styled with [Tailwind CSS v4](https://tailwindcss.com)
|
||||
- Deployed on [Vercel](https://vercel.com)
|
||||
|
||||
## 🚀 Local Development
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/FoggyMtnDrifter/website.git
|
||||
cd website
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start the dev server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **Build for production**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🛠️ Configuration
|
||||
|
||||
Site configuration is centralized in `src/site.config.ts`. This file controls:
|
||||
- SEO metadata (Title, Description, Author)
|
||||
- Navigation links
|
||||
- Theme settings
|
||||
- Social links & Giscus configuration
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE.txt).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Based on the [MultiTerm Astro](https://github.com/stelcodes/multiterm-astro) theme by [Stel](https://github.com/stelcodes).
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import svelte from '@astrojs/svelte'
|
||||
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import expressiveCode from 'astro-expressive-code'
|
||||
import siteConfig from './src/site.config'
|
||||
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
|
||||
import remarkDescription from './src/plugins/remark-description' /* Add description to frontmatter */
|
||||
import remarkReadingTime from './src/plugins/remark-reading-time' /* Add reading time to frontmatter */
|
||||
import rehypeTitleFigure from './src/plugins/rehype-title-figure' /* Wraps titles in figures */
|
||||
import { remarkGithubCard } from './src/plugins/remark-github-card'
|
||||
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
|
||||
import rehypeExternalLinks from 'rehype-external-links'
|
||||
import remarkDirective from 'remark-directive' /* Handle ::: directives as nodes */
|
||||
import rehypeUnwrapImages from 'rehype-unwrap-images'
|
||||
import { remarkAdmonitions } from './src/plugins/remark-admonitions' /* Add admonitions */
|
||||
import remarkCharacterDialogue from './src/plugins/remark-character-dialogue' /* Custom plugin to handle character admonitions */
|
||||
import remarkUnknownDirectives from './src/plugins/remark-unknown-directives' /* Custom plugin to handle unknown admonitions */
|
||||
import remarkMath from 'remark-math' /* for latex math support */
|
||||
import rehypeKatex from 'rehype-katex' /* again, for latex math support */
|
||||
import remarkGemoji from './src/plugins/remark-gemoji' /* for shortcode emoji support */
|
||||
import rehypePixelated from './src/plugins/rehype-pixelated' /* Custom plugin to handle pixelated images */
|
||||
import vercel from '@astrojs/vercel'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
adapter: vercel(),
|
||||
site: siteConfig.site,
|
||||
trailingSlash: siteConfig.trailingSlashes ? 'always' : 'never',
|
||||
prefetch: true,
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
[remarkDescription, { maxChars: 200 }],
|
||||
remarkReadingTime,
|
||||
remarkDirective,
|
||||
remarkGithubCard,
|
||||
remarkAdmonitions,
|
||||
[remarkCharacterDialogue, { characters: siteConfig.characters }],
|
||||
remarkUnknownDirectives,
|
||||
remarkMath,
|
||||
remarkGemoji,
|
||||
],
|
||||
rehypePlugins: [
|
||||
[rehypeHeadingIds, { headingIdCompat: true }],
|
||||
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
|
||||
rehypeTitleFigure,
|
||||
[
|
||||
rehypeExternalLinks,
|
||||
{
|
||||
rel: ['noreferrer', 'noopener'],
|
||||
target: '_blank',
|
||||
},
|
||||
],
|
||||
rehypeUnwrapImages,
|
||||
rehypePixelated,
|
||||
rehypeKatex,
|
||||
],
|
||||
},
|
||||
image: {
|
||||
responsiveStyles: true,
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
integrations: [
|
||||
svelte(),
|
||||
sitemap(),
|
||||
expressiveCode({
|
||||
themes: siteConfig.themes.include,
|
||||
useDarkModeMediaQuery: false,
|
||||
defaultProps: {
|
||||
showLineNumbers: false,
|
||||
wrap: false,
|
||||
},
|
||||
plugins: [pluginLineNumbers()],
|
||||
}), // Must come after expressive-code integration
|
||||
mdx(),
|
||||
],
|
||||
experimental: {
|
||||
contentIntellisense: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
The `theme-data.json` file is auto-generated and only used during theme development as a reference. It is not imported or required to build the site. Feel free to delete the whole `dev` directory if you like.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { loadShikiTheme, type BundledShikiTheme } from 'astro-expressive-code'
|
||||
import { bundledThemes } from 'shiki'
|
||||
import { flattenThemeColors } from '~/utils'
|
||||
|
||||
// Use this function to export theme data for analysis
|
||||
const exportThemeData = async () => {
|
||||
const keyArrays = await Promise.all(
|
||||
Object.keys(bundledThemes).map(async (theme) => {
|
||||
const exTheme = await loadShikiTheme(theme as BundledShikiTheme)
|
||||
const flatTheme = flattenThemeColors(exTheme)
|
||||
return Object.keys(flatTheme)
|
||||
}),
|
||||
)
|
||||
// Find intersection of all key arrays
|
||||
const commonKeys = keyArrays.reduce((acc, keys) =>
|
||||
acc.filter((key) => keys.includes(key)),
|
||||
)
|
||||
const allKeys = keyArrays.flat()
|
||||
const keyCount = allKeys.reduce((acc: Record<string, number>, key) => {
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
// Filter keys that appear in less than 10% of themes
|
||||
// and sort them alphabetically
|
||||
const sortedEntries = Object.entries(keyCount)
|
||||
.filter(([_, count]) => count > Math.ceil(Object.keys(bundledThemes).length / 10))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const jsonData = JSON.stringify(
|
||||
{
|
||||
allKeys: Object.fromEntries(sortedEntries),
|
||||
commonKeys: commonKeys.sort(),
|
||||
bundledThemes: Object.keys(bundledThemes).sort(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
const outputPath = './data/theme-data.json'
|
||||
// Write to file
|
||||
const fs = await import('fs/promises')
|
||||
await fs.writeFile(outputPath, jsonData, 'utf-8')
|
||||
console.log(`Theme data written to ${outputPath}`)
|
||||
}
|
||||
|
||||
await exportThemeData()
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "multiterm-astro",
|
||||
"description": "A terminal-inspired coding blog built with Astro.",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"postbuild": "pagefind --site dist",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "^6.3.6",
|
||||
"@astrojs/mdx": "^4.3.4",
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/sitemap": "^3.5.1",
|
||||
"@astrojs/svelte": "^7.2.5",
|
||||
"@astrojs/ts-plugin": "^1.10.4",
|
||||
"@astrojs/vercel": "^9.0.4",
|
||||
"@expo-google-fonts/jetbrains-mono": "^0.4.0",
|
||||
"@expressive-code/plugin-line-numbers": "^0.41.3",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.6",
|
||||
"@fontsource/varela": "^5.2.8",
|
||||
"@pagefind/default-ui": "^1.4.0",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^8.6.4",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.5",
|
||||
"astro-expressive-code": "^0.41.3",
|
||||
"color": "^5.0.0",
|
||||
"cookie": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-regex": "^10.5.0",
|
||||
"gemoji": "^8.1.0",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hastscript": "^9.0.1",
|
||||
"katex": "^0.16.22",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mdast-util-directive": "^3.1.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"octokit": "^5.0.5",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-unwrap-images": "^1.0.0",
|
||||
"remark-directive": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"satori": "^0.18.2",
|
||||
"satori-html": "^0.3.2",
|
||||
"stripe": "^20.2.0",
|
||||
"svelte": "^5.49.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.9.2",
|
||||
"unified": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"pagefind": "^1.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"sass-embedded": "^1.92.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Typescript file would be better but it's currently experimental
|
||||
|
||||
const config = {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 90,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
plugins: ['prettier-plugin-astro'],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.astro',
|
||||
options: {
|
||||
parser: 'astro',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,6 @@
|
||||
<>
|
||||
<h1 class="text-accent text-[1.6rem] pb-2.5 pl-1 mt-4 md:mt-7 font-semibold">
|
||||
<slot />
|
||||
</h1>
|
||||
<div class="border-1 border-accent/30 rounded-xl h-1 w-full"></div>
|
||||
</>
|
||||
@@ -0,0 +1,469 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
|
||||
let dialog: HTMLDialogElement
|
||||
let stripe: Stripe | null = null
|
||||
let elements: StripeElements | null = null
|
||||
let paymentElement: any = null /* Stripe Payment Element */
|
||||
|
||||
let isOpen = false
|
||||
let step: 'selection' | 'payment' | 'success' = 'selection'
|
||||
let amount = 5
|
||||
let customAmount: number | null = null
|
||||
let isLoading = false
|
||||
let error: string | null = null
|
||||
|
||||
// Helper to check if a number is valid amount
|
||||
$: finalAmount = customAmount && customAmount > 0 ? customAmount : amount
|
||||
$: isCustom = customAmount && customAmount > 0
|
||||
|
||||
onMount(() => {
|
||||
// Listen for triggers
|
||||
const handleTrigger = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const trigger = target.closest('.bmc-trigger')
|
||||
if (trigger) {
|
||||
e.preventDefault()
|
||||
openModal()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleTrigger)
|
||||
|
||||
// Check hash immediately
|
||||
if (window.location.hash === '#coffee') {
|
||||
openModal()
|
||||
}
|
||||
|
||||
// Listen for hash changes (e.g. if user clicks a link to #coffee while already on page)
|
||||
const handleHashChange = () => {
|
||||
if (window.location.hash === '#coffee') {
|
||||
openModal()
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
|
||||
// Async initialization
|
||||
;(async () => {
|
||||
stripe = await loadStripe(import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||
})()
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('click', handleTrigger)
|
||||
window.removeEventListener('hashchange', handleHashChange)
|
||||
}
|
||||
})
|
||||
|
||||
function openModal() {
|
||||
if (!dialog) return
|
||||
isOpen = true
|
||||
step = 'selection'
|
||||
error = null
|
||||
isLoading = false
|
||||
dialog.showModal()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (!dialog) return
|
||||
// Small delay to allow fade out if needed, but for native dialog close() handles it immediately usually.
|
||||
// We will perform reset after closing.
|
||||
dialog.close()
|
||||
isOpen = false
|
||||
setTimeout(() => {
|
||||
resetState()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
step = 'selection'
|
||||
error = null
|
||||
isLoading = false
|
||||
// destroy stripe elements if they exist
|
||||
if (paymentElement) {
|
||||
paymentElement.destroy()
|
||||
paymentElement = null
|
||||
elements = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const rect = dialog.getBoundingClientRect()
|
||||
const isInDialog =
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width
|
||||
if (!isInDialog) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
async function startPayment() {
|
||||
if (finalAmount < 5) {
|
||||
alert('Please enter an amount of at least $5.00')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/payment-intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: finalAmount,
|
||||
isCustom: isCustom,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
|
||||
const clientSecret = data.clientSecret
|
||||
if (!stripe) throw new Error('Stripe failed to load')
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const getVar = (name: string) => computedStyle.getPropertyValue(name).trim()
|
||||
|
||||
elements = stripe.elements({
|
||||
clientSecret,
|
||||
fonts: [
|
||||
{
|
||||
cssSrc: 'https://fonts.googleapis.com/css2?family=Varela&display=swap',
|
||||
},
|
||||
],
|
||||
appearance: {
|
||||
theme: 'night',
|
||||
variables: {
|
||||
colorPrimary: getVar('--theme-accent'),
|
||||
colorBackground: getVar('--theme-background'),
|
||||
colorText: getVar('--theme-foreground'),
|
||||
colorDanger: getVar('--theme-red'),
|
||||
fontFamily: 'Varela, sans-serif',
|
||||
borderRadius: '0.75rem',
|
||||
},
|
||||
rules: {
|
||||
'.Input': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 10%, transparent)',
|
||||
borderColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'.Input:focus': {
|
||||
borderColor: 'var(--colorPrimary)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 15%, transparent)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'.Tab': {
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 5%, transparent)',
|
||||
},
|
||||
'.Tab:hover': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 10%, transparent)',
|
||||
},
|
||||
'.Tab--selected': {
|
||||
borderColor: 'var(--colorPrimary)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 15%, transparent)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'.Block': {
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'.Label': {
|
||||
fontWeight: '500',
|
||||
color: 'var(--colorText)',
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
paymentElement = elements.create('payment')
|
||||
// Mount happens in markup via action or just bind:this, but here we wait for step change
|
||||
step = 'payment'
|
||||
|
||||
// Wait for DOM update
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById('payment-element-container')
|
||||
if (container && paymentElement) {
|
||||
paymentElement.mount('#payment-element-container')
|
||||
}
|
||||
}, 0)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
error = (err as Error).message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPayment() {
|
||||
isLoading = true
|
||||
error = null
|
||||
|
||||
if (!stripe || !elements) return
|
||||
|
||||
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/?success=true`,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
|
||||
if (stripeError) {
|
||||
error = stripeError.message || 'Payment failed'
|
||||
isLoading = false
|
||||
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
|
||||
step = 'success'
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRadioChange(val: number) {
|
||||
amount = val
|
||||
customAmount = null
|
||||
}
|
||||
|
||||
function handleCustomInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (val) {
|
||||
customAmount = parseFloat(val)
|
||||
} else {
|
||||
customAmount = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
on:close={closeModal}
|
||||
on:click={handleOutsideClick}
|
||||
class="backdrop:bg-black/50 bg-background border-2 border-accent/20 rounded-xl p-0 w-[90%] max-w-md shadow-2xl transition-all duration-300 text-foreground m-auto"
|
||||
>
|
||||
<div class="relative overflow-hidden">
|
||||
<!-- Header -->
|
||||
{#if step === 'selection'}
|
||||
<div
|
||||
in:fly={{ y: -20, duration: 300 }}
|
||||
out:fade={{ duration: 200 }}
|
||||
class="bg-accent/10 text-center border-b border-accent/10 p-6"
|
||||
>
|
||||
<h2
|
||||
class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-accent to-accent/70"
|
||||
>
|
||||
Buy Me a Coffee
|
||||
</h2>
|
||||
<p class="text-sm text-foreground/70 mt-2">
|
||||
Keep the terminal running and the caffeine flowing. Support my projects and
|
||||
content.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={closeModal}
|
||||
class="absolute top-4 right-4 text-foreground/50 hover:text-foreground transition-colors cursor-pointer z-10 bg-transparent border-0 p-0"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6">
|
||||
{#if step === 'selection'}
|
||||
<div
|
||||
in:fly={{ x: -20, duration: 300, delay: 150 }}
|
||||
out:fly={{ x: -20, duration: 200 }}
|
||||
class="w-full"
|
||||
>
|
||||
<!-- Amount Selector -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-6">
|
||||
{#each [5, 10, 20] as val}
|
||||
<label class="cursor-pointer relative">
|
||||
<input
|
||||
type="radio"
|
||||
name="amount"
|
||||
value={val}
|
||||
checked={amount === val && !customAmount}
|
||||
on:change={() => handleRadioChange(val)}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<div
|
||||
class="h-12 flex items-center justify-center rounded-lg border-2 border-accent/20 peer-checked:border-accent peer-checked:bg-accent/10 hover:border-accent/50 transition-all font-bold"
|
||||
>
|
||||
${val}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2 text-foreground/80">
|
||||
Custom Amount (USD)
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/50"
|
||||
>$</span
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.50"
|
||||
min="5"
|
||||
placeholder="Other amount"
|
||||
value={customAmount || ''}
|
||||
on:input={handleCustomInput}
|
||||
class="w-full bg-background border-2 border-accent/20 rounded-lg py-2 pl-8 pr-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={startPayment}
|
||||
disabled={isLoading}
|
||||
class="w-full bg-accent hover:bg-accent/90 text-background font-bold py-3 px-6 rounded-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
{#if isLoading}
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle><path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path></svg
|
||||
>
|
||||
Loading...
|
||||
{:else}
|
||||
Enter Payment Details
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else if step === 'payment'}
|
||||
<div
|
||||
in:fly={{ x: 20, duration: 300, delay: 150 }}
|
||||
out:fly={{ x: 20, duration: 200 }}
|
||||
class="w-full"
|
||||
>
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-sm text-foreground/60">Your Contribution:</p>
|
||||
<p class="text-3xl font-bold text-accent mt-1">
|
||||
${finalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="payment-element-container" class="mb-4 min-h-[100px]"></div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-sm text-red-500 text-center mb-4">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={confirmPayment}
|
||||
disabled={isLoading}
|
||||
class="w-full bg-accent hover:bg-accent/90 text-background font-bold py-3 px-6 rounded-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
{#if isLoading}
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle><path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path></svg
|
||||
>
|
||||
Processing...
|
||||
{:else}
|
||||
Submit Donation
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else if step === 'success'}
|
||||
<div in:fly={{ y: 20, duration: 300 }} class="w-full text-center py-8">
|
||||
<div class="text-6xl mb-4">☕️</div>
|
||||
<h3
|
||||
class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-accent to-accent/70 mb-2"
|
||||
>
|
||||
Thank you for your support!
|
||||
</h3>
|
||||
<p class="text-foreground/80 mb-6 px-4">
|
||||
Your contribution helps keep my projects and content alive. I truly appreciate
|
||||
it!
|
||||
</p>
|
||||
<button
|
||||
on:click={closeModal}
|
||||
class="w-full bg-accent/10 hover:bg-accent/20 text-accent font-bold py-2 px-4 rounded-lg transition-colors border border-accent/20"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-center text-foreground/40 mt-4">
|
||||
Payments Secured by Stripe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
animation: zoom-in 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes zoom-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,486 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let slug: string
|
||||
|
||||
let comments: any[] = []
|
||||
let user: any = null
|
||||
let isLoading = true
|
||||
let isSubmitting = false
|
||||
let error = ''
|
||||
|
||||
let newComment = ''
|
||||
let displayName = ''
|
||||
let authMode: 'guest' | 'github' = 'guest'
|
||||
|
||||
// Captcha State
|
||||
let num1 = Math.floor(Math.random() * 10) + 1
|
||||
let num2 = Math.floor(Math.random() * 10) + 1
|
||||
let captchaAnswer = ''
|
||||
// Honeypot State
|
||||
let honeyPot = ''
|
||||
|
||||
// Fetch User
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/user')
|
||||
const data = await res.json()
|
||||
user = data.user
|
||||
if (user) authMode = 'github'
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Comments
|
||||
async function fetchComments() {
|
||||
isLoading = true
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${slug}`)
|
||||
const data = await res.json()
|
||||
if (data.discussion) {
|
||||
comments = data.discussion.comments.nodes
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to load comments'
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([checkAuth(), fetchComments()])
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!newComment.trim()) return
|
||||
if (authMode === 'guest' && !displayName.trim()) {
|
||||
alert('Please enter a display name')
|
||||
return
|
||||
}
|
||||
|
||||
// Basic client-side validation for captcha if guest
|
||||
if (!user && authMode === 'guest') {
|
||||
if (parseInt(captchaAnswer) !== num1 + num2) {
|
||||
alert('Incorrect math answer. Please try again.')
|
||||
// Reset
|
||||
num1 = Math.floor(Math.random() * 10) + 1
|
||||
num2 = Math.floor(Math.random() * 10) + 1
|
||||
captchaAnswer = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${slug}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: newComment,
|
||||
displayName: authMode === 'guest' ? displayName : undefined,
|
||||
website_honey: honeyPot,
|
||||
captcha: !user ? { num1, num2, answer: captchaAnswer } : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text()
|
||||
throw new Error(txt || 'Failed to post')
|
||||
}
|
||||
|
||||
const addedComment = await res.json()
|
||||
comments = [addedComment, ...comments] // Prepend new comment
|
||||
newComment = ''
|
||||
captchaAnswer = ''
|
||||
// Reset Captcha
|
||||
num1 = Math.floor(Math.random() * 10) + 1
|
||||
num2 = Math.floor(Math.random() * 10) + 1
|
||||
} catch (e: any) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function signIn() {
|
||||
// Save current path to redirect back
|
||||
const redirect = window.location.pathname
|
||||
window.location.href = `/api/auth/signin?redirect_to=${encodeURIComponent(redirect)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comments-section not-prose mt-12">
|
||||
<!-- Comment Form (Collapsible) -->
|
||||
<details
|
||||
class="group mb-12 border border-accent/20 rounded-2xl bg-foreground/3 open:bg-foreground/5 transition-colors"
|
||||
open={comments.length === 0}
|
||||
>
|
||||
<summary
|
||||
class="flex items-center justify-between p-4 cursor-pointer list-none font-semibold text-heading1 select-none"
|
||||
>
|
||||
<span>Leave a comment</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-accent transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="px-6 pb-6 pt-2 border-t border-accent/10">
|
||||
{#if !user}
|
||||
<div class="flex gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-3 px-4 rounded-lg border-2 font-bold transition-all text-sm {authMode ===
|
||||
'guest'
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-accent/20 hover:border-accent/50 text-foreground/70'}"
|
||||
on:click={() => (authMode = 'guest')}
|
||||
>
|
||||
Post as Guest
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 font-bold transition-all text-sm {authMode ===
|
||||
'github'
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-accent/20 hover:border-accent/50 text-foreground/70'}"
|
||||
on:click={() => signIn()}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/></svg
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user}
|
||||
<div
|
||||
class="flex items-center gap-3 mb-6 p-4 bg-background border-2 border-accent/20 rounded-lg"
|
||||
>
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.login}
|
||||
class="w-10 h-10 rounded-full ring-2 ring-accent/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm text-foreground/60">Signed in as</div>
|
||||
<div class="font-bold text-heading1">{user.login}</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/signout"
|
||||
class="text-xs font-bold px-3 py-1.5 rounded-lg border border-accent/20 text-foreground/60 hover:text-red-400 hover:border-red-400/50 transition-colors"
|
||||
>Sign out</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-5">
|
||||
<!-- Honeypot -->
|
||||
<input
|
||||
type="text"
|
||||
name="website_honey"
|
||||
class="hidden"
|
||||
bind:value={honeyPot}
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if authMode === 'guest' && !user}
|
||||
<div>
|
||||
<label
|
||||
for="displayName"
|
||||
class="block text-sm font-medium text-foreground/80 mb-2 ml-1"
|
||||
>Display Name</label
|
||||
>
|
||||
<input
|
||||
id="displayName"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
placeholder="Jane Doe"
|
||||
class="w-full bg-background border-2 border-accent/20 rounded-lg py-2 px-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all text-foreground placeholder-foreground/30"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="comment"
|
||||
class="block text-sm font-medium text-foreground/80 mb-2 ml-1">Comment</label
|
||||
>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
id="comment"
|
||||
bind:value={newComment}
|
||||
rows="4"
|
||||
placeholder={authMode === 'guest'
|
||||
? 'Only simple markdown is supported.'
|
||||
: 'Write with full markdown support...'}
|
||||
class="w-full bg-background border-2 border-accent/20 rounded-lg py-2 px-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all text-foreground placeholder-foreground/30 resize-y min-h-[120px]"
|
||||
required
|
||||
></textarea>
|
||||
<div class="absolute bottom-3 right-3 flex gap-2">
|
||||
<a
|
||||
href="https://www.markdownguide.org/cheat-sheet/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hidden sm:inline-flex items-center text-[10px] text-foreground/30 hover:text-accent transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
Markdown supported
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Captcha for Guest -->
|
||||
{#if authMode === 'guest' && !user}
|
||||
<div>
|
||||
<label
|
||||
for="captcha"
|
||||
class="block text-sm font-medium text-foreground/80 mb-2 ml-1"
|
||||
>Human Check: What is {num1} + {num2}?</label
|
||||
>
|
||||
<input
|
||||
id="captcha"
|
||||
type="number"
|
||||
bind:value={captchaAnswer}
|
||||
placeholder="?"
|
||||
class="w-32 bg-background border-2 border-accent/20 rounded-lg py-2 px-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all text-foreground placeholder-foreground/30"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Global button style applied here by using the 'button' tag without override classes except layout -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
class="w-full bg-accent hover:bg-accent/90 text-background font-bold py-3 px-6 rounded-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle><path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path></svg
|
||||
>
|
||||
Posting...
|
||||
{:else}
|
||||
Post Comment
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8 text-foreground/60">
|
||||
<svg
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-accent"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Loading comments...
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-red-500 bg-red-500/10 p-4 rounded-lg border border-red-500/20">
|
||||
{error}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-8 mb-12">
|
||||
{#each comments as comment}
|
||||
<div class="comment-thread group">
|
||||
<!-- Main Comment -->
|
||||
<div class="flex gap-4">
|
||||
<img
|
||||
src={comment.author.avatarUrl}
|
||||
alt={comment.author.login}
|
||||
class="w-10 h-10 rounded-full border border-accent/20 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="bg-foreground/5 px-4 py-3 rounded-2xl border border-foreground/10 hover:border-accent/30 transition-colors"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={comment.author.url || '#'}
|
||||
target={comment.author.url ? '_blank' : ''}
|
||||
rel="noopener noreferrer"
|
||||
class="font-bold text-heading1 hover:text-accent transition-colors"
|
||||
>
|
||||
{comment.author.login}
|
||||
</a>
|
||||
{#if comment.author.login === 'FoggyMtnDrifter'}
|
||||
<span
|
||||
class="bg-accent/10 text-accent text-[10px] px-1.5 py-0.5 rounded-full font-bold border border-accent/20"
|
||||
>ADMIN</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-foreground/50 font-mono">
|
||||
{new Date(comment.createdAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-invert max-w-none text-sm text-foreground/90 leading-relaxed overflow-hidden break-words"
|
||||
>
|
||||
{@html comment.bodyHTML}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.nodes.length > 0}
|
||||
<div
|
||||
class="ml-5 sm:ml-14 mt-4 space-y-4 border-l-2 border-accent/10 pl-4 sm:pl-6"
|
||||
>
|
||||
{#each comment.replies.nodes as reply}
|
||||
<div class="flex gap-3">
|
||||
<img
|
||||
src={reply.author.avatarUrl}
|
||||
alt={reply.author.login}
|
||||
class="w-8 h-8 rounded-full border border-accent/20 flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 min-w-0 bg-foreground/3 px-4 py-3 rounded-xl border border-foreground/5 hover:border-accent/20 transition-colors"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={reply.author.url || '#'}
|
||||
target={reply.author.url ? '_blank' : ''}
|
||||
rel="noopener noreferrer"
|
||||
class="font-semibold text-sm text-heading1 hover:text-accent transition-colors"
|
||||
>
|
||||
{reply.author.login}
|
||||
</a>
|
||||
{#if reply.author.login === 'FoggyMtnDrifter'}
|
||||
<span
|
||||
class="bg-accent/10 text-accent text-[10px] px-1.5 py-0.5 rounded-full font-bold border border-accent/20"
|
||||
>ADMIN</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-[10px] text-foreground/50 font-mono">
|
||||
{new Date(reply.createdAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-invert max-w-none text-sm text-foreground/90 leading-relaxed break-words"
|
||||
>
|
||||
{@html reply.bodyHTML}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if comments.length === 0}
|
||||
<div
|
||||
class="text-center py-12 bg-foreground/3 rounded-2xl border border-foreground/5 border-dashed"
|
||||
>
|
||||
<p class="text-foreground/60">No comments yet.</p>
|
||||
<p class="text-sm text-accent mt-1">Be the first to post!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Add any custom styles here if not using Tailwind for everything */
|
||||
/* Target links inside the comments, ignoring global prose settings if needed or enhancing them */
|
||||
div.prose :global(a) {
|
||||
color: var(--color-accent); /* Use CSS variable for accent color */
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
div.prose :global(p) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
/* Override global prose margins that might be applied to direct children */
|
||||
div.prose > :global(*) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
div.prose :global(a:hover) {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
div.prose :global(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
div.prose :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<div
|
||||
id="cookie-consent"
|
||||
class="fixed bottom-4 left-4 z-50 hidden max-w-sm rounded-xl border border-accent/20 bg-background/95 p-4 shadow-lg backdrop-blur-sm transition-all duration-300"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-foreground/90">
|
||||
I use essential cookies to ensure my website functions properly. By continuing to
|
||||
use this site, you agree to my <a
|
||||
href="/legal/privacy-policy"
|
||||
class="text-accent hover:underline">Privacy Policy</a
|
||||
>.
|
||||
</p>
|
||||
<button
|
||||
id="dismiss-cookie-consent"
|
||||
class="self-start rounded-lg bg-accent/10 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 active:bg-accent/30 transition-colors"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Function to set a cookie
|
||||
function setCookie(name: string, value: string, days: number) {
|
||||
let expires = ''
|
||||
if (days) {
|
||||
const date = new window.Date()
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
expires = '; expires=' + date.toUTCString()
|
||||
}
|
||||
document.cookie = name + '=' + (value || '') + expires + '; path=/'
|
||||
}
|
||||
|
||||
// Function to get a cookie
|
||||
function getCookie(name: string) {
|
||||
const nameEQ = name + '='
|
||||
const ca = document.cookie.split(';')
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i]
|
||||
while (c.charAt(0) == ' ') c = c.substring(1, c.length)
|
||||
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CONSENT_COOKIE_NAME = 'cookie-consent-dismissed'
|
||||
const component = document.getElementById('cookie-consent')
|
||||
const dismissButton = document.getElementById('dismiss-cookie-consent')
|
||||
|
||||
// Check if the consent cookie exists
|
||||
if (!getCookie(CONSENT_COOKIE_NAME)) {
|
||||
// Show the popup after a small delay for a smoother entrance
|
||||
setTimeout(() => {
|
||||
component?.classList.remove('hidden')
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// Handle dismiss click
|
||||
dismissButton?.addEventListener('click', () => {
|
||||
// Set cookie for 365 days
|
||||
setCookie(CONSENT_COOKIE_NAME, 'true', 365)
|
||||
// Hide the popup
|
||||
component?.classList.add('opacity-0', 'translate-y-4')
|
||||
setTimeout(() => {
|
||||
component?.classList.add('hidden')
|
||||
}, 300)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
interface Props {
|
||||
text: string
|
||||
}
|
||||
const { text } = Astro.props
|
||||
---
|
||||
|
||||
<div class="flex gap-3 items-center my-4">
|
||||
<hr class="flex-1 bg-accent h-0.5 border-none" />
|
||||
<span class="shrink-0 whitespace-nowrap">{text}</span>
|
||||
<hr class="flex-1 bg-accent h-0.5 border-none" />
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import siteConfig from '~/site.config'
|
||||
import SocialLinks from '~/components/SocialLinks.astro'
|
||||
---
|
||||
|
||||
<footer
|
||||
class="mt-auto pt-9 md:pt-11 grow-0 flex flex-col gap-6 items-center justify-between max-w-full text-foreground/80"
|
||||
>
|
||||
{siteConfig.socialLinks && <SocialLinks socialLinks={siteConfig.socialLinks} />}
|
||||
<div class="flex flex-col md:flex-row flex-wrap flex-1 items-center justify-center">
|
||||
<span class="my-1">
|
||||
© 2025 {siteConfig.author}
|
||||
</span>
|
||||
<span class="mx-5 hidden md:block"> :: </span>
|
||||
<span class="my-1">
|
||||
<a class="underline mx-2" href="/legal/terms-of-use">Terms of Use</a>
|
||||
<a class="underline mx-2" href="/legal/privacy-policy">Privacy Policy</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
import {
|
||||
differenceInCalendarDays,
|
||||
eachDayOfInterval,
|
||||
formatISO,
|
||||
getDay,
|
||||
nextDay,
|
||||
parseISO,
|
||||
subWeeks,
|
||||
getYear,
|
||||
getMonth,
|
||||
isValid,
|
||||
} from 'date-fns'
|
||||
import type {
|
||||
WeekdayIndex,
|
||||
GitHubActivityApiResponse,
|
||||
GitHubActivityDay,
|
||||
GitHubActivityWeek,
|
||||
GitHubActivityMonthLabel,
|
||||
} from '~/types'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
year?: number
|
||||
}
|
||||
|
||||
function range(n: number) {
|
||||
return [...Array(n).keys()]
|
||||
}
|
||||
|
||||
async function fetchData(
|
||||
username: string,
|
||||
year: number | 'last',
|
||||
): Promise<GitHubActivityApiResponse> {
|
||||
function validateActivities(activities: Array<GitHubActivityDay>) {
|
||||
if (activities.length === 0) {
|
||||
throw new Error('Activity data must not be empty.')
|
||||
}
|
||||
for (const { date, count } of activities) {
|
||||
if (!isValid(parseISO(date))) {
|
||||
throw new Error(`Activity date '${date}' is not a valid ISO 8601 date string.`)
|
||||
}
|
||||
if (count < 0) {
|
||||
throw new RangeError(`Activity count must not be negative, found ${count}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const apiUrl = 'https://github-contributions-api.jogruber.de/v4/'
|
||||
const response = await fetch(`${apiUrl}${username}?y=${String(year)}`)
|
||||
const data = (await response.json()) as GitHubActivityApiResponse
|
||||
if (!response.ok) {
|
||||
const message = data.error || 'Unknown error'
|
||||
throw Error(`Fetching GitHub contribution data for "${username}" failed: ${message}`)
|
||||
}
|
||||
|
||||
validateActivities(data.contributions)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function calcColorScale([start, end]: [string, string], steps: number): Array<string> {
|
||||
return range(steps).map((i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return start
|
||||
case steps - 1:
|
||||
return end
|
||||
default: {
|
||||
const pos = (i / (steps - 1)) * 100
|
||||
return `color-mix(in oklab, ${end} ${parseFloat(pos.toFixed(2))}%, ${start})`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fillHoles(activities: Array<GitHubActivityDay>): Array<GitHubActivityDay> {
|
||||
const calendar = new Map<string, GitHubActivityDay>(activities.map((a) => [a.date, a]))
|
||||
const firstActivity = activities[0] as GitHubActivityDay
|
||||
const lastActivity = activities[activities.length - 1] as GitHubActivityDay
|
||||
|
||||
return eachDayOfInterval({
|
||||
start: parseISO(firstActivity.date),
|
||||
end: parseISO(lastActivity.date),
|
||||
}).map((day) => {
|
||||
const date = formatISO(day, { representation: 'date' })
|
||||
if (calendar.has(date)) {
|
||||
return calendar.get(date) as GitHubActivityDay
|
||||
}
|
||||
return {
|
||||
date,
|
||||
count: 0,
|
||||
level: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function groupByWeeks(
|
||||
activities: Array<GitHubActivityDay>,
|
||||
weekStart: WeekdayIndex = 0, // 0 = Sunday
|
||||
): Array<GitHubActivityWeek> {
|
||||
const normalizedActivities = fillHoles(activities)
|
||||
// Determine the first date of the calendar. If the first date is not the
|
||||
// passed weekday, the respective weekday one week earlier is used.
|
||||
const firstActivity = normalizedActivities[0] as GitHubActivityDay
|
||||
const firstDate = parseISO(firstActivity.date)
|
||||
const firstCalendarDate =
|
||||
getDay(firstDate) === weekStart
|
||||
? firstDate
|
||||
: subWeeks(nextDay(firstDate, weekStart), 1)
|
||||
// To correctly group activities by week, it is necessary to left-pad the list
|
||||
// because the first date might not be set start weekday.
|
||||
const paddedActivities = [
|
||||
...(Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(
|
||||
undefined,
|
||||
) as Array<GitHubActivityDay>),
|
||||
...normalizedActivities,
|
||||
]
|
||||
const numberOfWeeks = Math.ceil(paddedActivities.length / 7)
|
||||
|
||||
// Finally, group activities by week
|
||||
return [...Array(numberOfWeeks).keys()].map((weekIndex) =>
|
||||
paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7),
|
||||
)
|
||||
}
|
||||
|
||||
function getMonthLabels(
|
||||
weeks: Array<GitHubActivityWeek>,
|
||||
monthNames: Array<string>,
|
||||
): Array<GitHubActivityMonthLabel> {
|
||||
return weeks
|
||||
.reduce<Array<GitHubActivityMonthLabel>>((labels, week, weekIndex) => {
|
||||
const firstActivity = week.find((activity) => activity !== undefined)
|
||||
if (!firstActivity) {
|
||||
throw new Error(`Unexpected error: Week ${weekIndex + 1} is empty.`)
|
||||
}
|
||||
const month = monthNames[getMonth(parseISO(firstActivity.date))]
|
||||
if (!month) {
|
||||
const monthName = new Date(firstActivity.date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
})
|
||||
throw new Error(`Unexpected error: undefined month label for ${monthName}.`)
|
||||
}
|
||||
const prevLabel = labels[labels.length - 1]
|
||||
if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {
|
||||
return [...labels, { weekIndex, label: month }]
|
||||
}
|
||||
return labels
|
||||
}, [])
|
||||
.filter(({ weekIndex }, index, labels) => {
|
||||
const minWeeks = 3
|
||||
// Skip the first month label if there is not enough space to the next one.
|
||||
if (index === 0) {
|
||||
return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks
|
||||
}
|
||||
// Skip the last month label if there is not enough data in that month
|
||||
if (index === labels.length - 1) {
|
||||
return weeks.slice(weekIndex).length >= minWeeks
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const { username, year = 'last' } = Astro.props
|
||||
|
||||
const data = await fetchData(username, year)
|
||||
|
||||
const themeFromColorscheme: [string, string] = [
|
||||
'var(--theme-background)',
|
||||
'var(--theme-accent)',
|
||||
]
|
||||
|
||||
const totalCount = year === 'last' ? data.total.lastYear : data.total[year]
|
||||
const maxLevel = 4
|
||||
const blockMargin = 4
|
||||
const labelMargin = 8
|
||||
const blockRadius = 2
|
||||
const blockSize = 12
|
||||
const fontSize = 14
|
||||
const hideColorLegend = false
|
||||
const hideMonthLabels = false
|
||||
const hideTotalCount = false
|
||||
const weekStart = 0 // 0 = Sunday, 1 = Monday, etc.
|
||||
|
||||
const colorScale = calcColorScale(themeFromColorscheme, maxLevel + 1)
|
||||
const activities = data.contributions
|
||||
|
||||
const firstActivity = activities[0]
|
||||
const activityYear = getYear(parseISO(firstActivity.date))
|
||||
const weeks = groupByWeeks(activities, weekStart)
|
||||
const labels = {
|
||||
months: [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
],
|
||||
totalCount: `{{count}} contributions in ${year === 'last' ? 'the last year' : '{{year}}'}`,
|
||||
legend: {
|
||||
less: 'Less',
|
||||
more: 'More',
|
||||
},
|
||||
}
|
||||
const labelHeight = hideMonthLabels ? 0 : fontSize + labelMargin
|
||||
const width = weeks.length * (blockSize + blockMargin) - blockMargin
|
||||
const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin
|
||||
---
|
||||
|
||||
<article
|
||||
id="github-activity-calendar"
|
||||
class="w-max max-w-full flex flex-col gap-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="max-w-full overflow-x-auto pt-0.5"
|
||||
style={{
|
||||
// Don't cover the calendar with the scrollbar.
|
||||
scrollbarGutter: 'stable',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="block visible"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{
|
||||
!hideMonthLabels && (
|
||||
<g>
|
||||
{getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => (
|
||||
<text
|
||||
x={(blockSize + blockMargin) * weekIndex}
|
||||
y={0}
|
||||
dominant-baseline="hanging"
|
||||
fill="currentColor"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
{
|
||||
weeks.map((week, weekIndex) => (
|
||||
<g transform={`translate(${(blockSize + blockMargin) * weekIndex}, 0)`}>
|
||||
{week.map((activity, dayIndex) => {
|
||||
if (!activity) return null
|
||||
return (
|
||||
<rect
|
||||
class="stroke-foreground/10"
|
||||
x={0}
|
||||
y={labelHeight + (blockSize + blockMargin) * dayIndex}
|
||||
width={blockSize}
|
||||
height={blockSize}
|
||||
rx={blockRadius}
|
||||
ry={blockRadius}
|
||||
fill={colorScale[activity.level]}
|
||||
data-date={activity.date}
|
||||
data-level={activity.level}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
))
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
{
|
||||
!(hideTotalCount && hideColorLegend) && (
|
||||
<footer class="flex flex-col sm:flex-row sm:justify-between gap-x-1 gap-y-2">
|
||||
{!hideTotalCount && (
|
||||
<div>
|
||||
{labels.totalCount
|
||||
? labels.totalCount
|
||||
.replace('{{count}}', String(totalCount))
|
||||
.replace('{{year}}', String(activityYear))
|
||||
: `${totalCount} activities in ${activityYear}`}
|
||||
</div>
|
||||
)}
|
||||
{!hideColorLegend && (
|
||||
<div class="flex items-center gap-[3px]">
|
||||
<span class="mr-1.5">{labels.legend.less}</span>
|
||||
{range(maxLevel + 1).map((level) => (
|
||||
<svg width={blockSize} height={blockSize}>
|
||||
<rect
|
||||
class="stroke-foreground/10"
|
||||
width={blockSize}
|
||||
height={blockSize}
|
||||
fill={colorScale[level]}
|
||||
rx={blockRadius}
|
||||
ry={blockRadius}
|
||||
/>
|
||||
</svg>
|
||||
))}
|
||||
<span class="ml-1.5">{labels.legend.more}</span>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import siteConfig from '~/site.config'
|
||||
import LightDarkAutoButton from '~/components/LightDarkAutoButton.astro'
|
||||
import Search from '~/components/Search.astro'
|
||||
import SelectTheme from '~/components/SelectTheme.astro'
|
||||
import NavLink from '~/components/NavLink.astro'
|
||||
|
||||
const lightDarkAutoTheme = siteConfig.themes.mode === 'light-dark-auto'
|
||||
const selectTheme =
|
||||
siteConfig.themes.mode === 'select' && siteConfig.themes.include.length > 1
|
||||
---
|
||||
|
||||
<header>
|
||||
<div class="relative flex items-center justify-between bg-accent/10 rounded-xl">
|
||||
<a
|
||||
id="logo"
|
||||
href="/"
|
||||
class="block px-4 py-1.5 max-w-full no-underline items-center bg-accent text-background font-bold rounded-xl"
|
||||
>
|
||||
{siteConfig.title}
|
||||
</a>
|
||||
<div class="flex items-center gap-3 sm:mr-3">
|
||||
<Search trailingSlashes={siteConfig.trailingSlashes} />
|
||||
{lightDarkAutoTheme && <LightDarkAutoButton />}
|
||||
{selectTheme && <SelectTheme />}
|
||||
<nav id="nav-mobile" aria-label="Menu" class="p-0 text-accent sm:hidden">
|
||||
<button
|
||||
id="nav-mobile-button"
|
||||
class="px-3 py-1 h-full cursor-pointer border-2 rounded-xl bg-background"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="nav-menu-list"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
<ul
|
||||
id="nav-mobile-list"
|
||||
class="invisible absolute flex flex-col bg-background shadow text-accent border-2 m-0 p-2.5 top-11.5 left-auto right-0 z-50 rounded-xl"
|
||||
>
|
||||
{
|
||||
siteConfig.navLinks.map((link) => (
|
||||
<li class="p-1" aria-expanded="false">
|
||||
<NavLink link={link} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li class="p-1" aria-expanded="false">
|
||||
<button
|
||||
type="button"
|
||||
class="bmc-trigger underline cursor-pointer text-left w-full"
|
||||
>
|
||||
Coffee?
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Menu" class="p-0 mt-4 ml-0.5 text-accent hidden sm:block">
|
||||
<ul class="flex flex-row text-accent">
|
||||
{
|
||||
siteConfig.navLinks.map((link) => (
|
||||
<li class="mr-5" aria-expanded="true">
|
||||
<NavLink link={link} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li class="mr-5" aria-expanded="true">
|
||||
<button type="button" class="bmc-trigger underline cursor-pointer">
|
||||
Coffee?
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const navMobileButton = document.getElementById('nav-mobile-button')
|
||||
const navMobileList = document.getElementById('nav-mobile-list')
|
||||
const navMobileListItems = navMobileList?.querySelectorAll('li')
|
||||
const toggleNavMobileMenu = (action: 'on' | 'off' | 'toggle') => {
|
||||
let isNowOpen: boolean = false
|
||||
if (action === 'on') {
|
||||
isNowOpen = true
|
||||
navMobileList?.classList.remove('invisible')
|
||||
} else if (action === 'off') {
|
||||
isNowOpen = false
|
||||
navMobileList?.classList.add('invisible')
|
||||
} else {
|
||||
isNowOpen = !navMobileList?.classList.toggle('invisible')
|
||||
}
|
||||
navMobileButton?.setAttribute('aria-expanded', isNowOpen ? 'true' : 'false')
|
||||
navMobileListItems?.forEach((listItem) => {
|
||||
listItem.setAttribute('aria-expanded', isNowOpen ? 'true' : 'false')
|
||||
})
|
||||
}
|
||||
navMobileButton?.addEventListener('click', (_ev) => {
|
||||
toggleNavMobileMenu('toggle')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import { Image } from 'astro:assets'
|
||||
import GitHubActivityCalendar from '~/components/GitHubActivityCalendar.astro'
|
||||
import type { FrontmatterImage } from '~/types'
|
||||
|
||||
interface Props {
|
||||
avatarImage?: FrontmatterImage
|
||||
githubCalendar?: string
|
||||
}
|
||||
const { avatarImage, githubCalendar } = Astro.props
|
||||
---
|
||||
|
||||
<div class="my-6 md:mx-2">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-around sm:gap-5 max-w-full mb-6"
|
||||
>
|
||||
{
|
||||
avatarImage && (
|
||||
<Image
|
||||
priority
|
||||
src={avatarImage.src}
|
||||
alt={avatarImage.alt}
|
||||
class="rounded-full border-8 border-accent/15 size-36 aspect-square box-content shrink-0"
|
||||
widths={[288]}
|
||||
height={144}
|
||||
width={144}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<div class:list={['m-4 max-w-full prose']}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{githubCalendar && <GitHubActivityCalendar username={githubCalendar} />}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
import IconSun from '~/icons/sun.svg'
|
||||
import IconMoon from '~/icons/moon.svg'
|
||||
import IconSunMoon from '~/icons/sun-moon.svg'
|
||||
import siteConfig from '~/site.config'
|
||||
let lightTheme = siteConfig.themes.include[0]
|
||||
let darkTheme = siteConfig.themes.include[1]
|
||||
---
|
||||
|
||||
<button
|
||||
id="theme-change-button"
|
||||
class="block ml-auto"
|
||||
data-light={lightTheme}
|
||||
data-dark={darkTheme}
|
||||
>
|
||||
<IconSun id="icon-light" class="hidden size-6 text-accent" />
|
||||
<IconMoon id="icon-dark" class="hidden size-6 text-accent" />
|
||||
<IconSunMoon id="icon-auto" class="hidden size-6 text-accent" />
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const themeButton = document.getElementById('theme-change-button')
|
||||
const lightThemeId = themeButton?.getAttribute('data-light')
|
||||
const darkThemeId = themeButton?.getAttribute('data-dark')
|
||||
const sunIcon = themeButton?.querySelector('#icon-light')
|
||||
const moonIcon = themeButton?.querySelector('#icon-dark')
|
||||
const sunMoonIcon = themeButton?.querySelector('#icon-auto')
|
||||
const updateIcons = (theme: string) => {
|
||||
if (theme === lightThemeId) {
|
||||
sunIcon?.classList.remove('hidden')
|
||||
moonIcon?.classList.add('hidden')
|
||||
sunMoonIcon?.classList.add('hidden')
|
||||
} else if (theme === darkThemeId) {
|
||||
sunIcon?.classList.add('hidden')
|
||||
moonIcon?.classList.remove('hidden')
|
||||
sunMoonIcon?.classList.add('hidden')
|
||||
} else {
|
||||
sunIcon?.classList.add('hidden')
|
||||
moonIcon?.classList.add('hidden')
|
||||
sunMoonIcon?.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
let currentTheme = localStorage.getItem('data-theme') || 'auto'
|
||||
updateIcons(currentTheme)
|
||||
themeButton?.addEventListener('click', () => {
|
||||
// Toggle between light, dark, and auto themes
|
||||
// Only need to update localStorage because there should be a listener in Layout.astro
|
||||
const currentTheme = localStorage.getItem('data-theme') || darkThemeId
|
||||
let newTheme
|
||||
if (currentTheme === lightThemeId) {
|
||||
newTheme = darkThemeId
|
||||
} else if (currentTheme === darkThemeId) {
|
||||
newTheme = 'auto'
|
||||
} else {
|
||||
newTheme = lightThemeId
|
||||
}
|
||||
if (newTheme) {
|
||||
updateIcons(newTheme)
|
||||
localStorage.setItem('data-theme', newTheme)
|
||||
if (newTheme === 'auto') {
|
||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
prefersDarkScheme.matches ? darkThemeId || 'auto' : lightThemeId || 'auto',
|
||||
)
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
;(function loadTheme() {
|
||||
const pageDefaultTheme = document.documentElement.getAttribute('data-theme')
|
||||
const pageDarkTheme = document.documentElement.getAttribute('data-dark-theme')
|
||||
const pageLightTheme = document.documentElement.getAttribute('data-light-theme')
|
||||
const pageThemeHash = document.documentElement.getAttribute('data-theme-hash')
|
||||
if (!pageDefaultTheme || !pageDarkTheme || !pageLightTheme || !pageThemeHash) {
|
||||
throw new Error('Theme attributes are required.')
|
||||
}
|
||||
const getStoredTheme = () => localStorage.getItem('data-theme')
|
||||
let storedTheme = getStoredTheme()
|
||||
const storedThemeHash = localStorage.getItem('data-theme-hash')
|
||||
const themeHashMatches = storedThemeHash === pageThemeHash
|
||||
|
||||
if (!storedTheme || !storedThemeHash || !themeHashMatches) {
|
||||
// Should be the first time loading the website
|
||||
localStorage.setItem('data-theme', pageDefaultTheme)
|
||||
localStorage.setItem('data-theme-hash', pageThemeHash)
|
||||
}
|
||||
|
||||
if (
|
||||
themeHashMatches &&
|
||||
storedTheme &&
|
||||
storedTheme !== 'auto' &&
|
||||
storedTheme !== pageDefaultTheme
|
||||
) {
|
||||
// The stored theme is different from the default theme, apply it
|
||||
document.documentElement.setAttribute('data-theme', storedTheme)
|
||||
} else if (pageDefaultTheme === 'auto' || storedTheme === 'auto') {
|
||||
// If the default or stored theme is 'auto', apply the system preference
|
||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
prefersDarkScheme.matches ? pageDarkTheme : pageLightTheme,
|
||||
)
|
||||
prefersDarkScheme.addEventListener('change', (e) => {
|
||||
if (getStoredTheme() === 'auto') {
|
||||
const newTheme = e.matches ? pageDarkTheme : pageLightTheme
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import type { NavLink } from '~/types'
|
||||
|
||||
interface Props {
|
||||
link: NavLink
|
||||
}
|
||||
|
||||
const { link } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
class="underline"
|
||||
rel={link.external ? 'noopener noreferrer' : undefined}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
href={link.url}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
interface Props {
|
||||
title?: string
|
||||
titlePieces?: string[]
|
||||
}
|
||||
|
||||
const { title, titlePieces } = Astro.props
|
||||
|
||||
const splitPath: string[] = Astro.url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean) // Remove empty parts
|
||||
.map((part) => decodeURIComponent(part)) // For Unicode chars kept by github-slugger
|
||||
|
||||
const pieces = titlePieces ?? splitPath
|
||||
|
||||
if (pieces.length === 0) {
|
||||
pieces.push('home')
|
||||
}
|
||||
|
||||
const titleFromPieces = pieces.map((part) => `/ ${part}`)
|
||||
---
|
||||
|
||||
<div
|
||||
class="border-0 border-accent/30 bg-accent/6 rounded-xl inline-block py-2.5 pl-3 pr-4 mt-5 md:mt-8"
|
||||
>
|
||||
<h1 class="text-accent text-2xl font-semibold flex flex-wrap gap-y-2 gap-x-3.5">
|
||||
{title ? <span>{title}</span> : titleFromPieces.map((part) => <span>{part}</span>)}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import IconChevronsLeft from '~/icons/chevrons-left.svg'
|
||||
import IconChevronsRight from '~/icons/chevrons-right.svg'
|
||||
|
||||
interface Props {
|
||||
prevLink?: string
|
||||
prevText?: string
|
||||
nextLink?: string
|
||||
nextText?: string
|
||||
}
|
||||
|
||||
const { prevLink, prevText, nextLink, nextText } = Astro.props
|
||||
---
|
||||
|
||||
<div class="my-5 flex flex-row flex-wrap gap-3 justify-center">
|
||||
{
|
||||
prevLink && (
|
||||
<a
|
||||
href={prevLink}
|
||||
aria-label="Previous Page"
|
||||
class="button flex-row items-center justify-center w-full !pr-3.5 sm:w-min"
|
||||
>
|
||||
<IconChevronsLeft class="mr-2 text-xl size-5" />
|
||||
{prevText || 'Previous'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
nextLink && (
|
||||
<a
|
||||
href={nextLink}
|
||||
aria-label="Next Page"
|
||||
class:list={[
|
||||
'button flex-row items-center justify-center w-full !pl-3.5 sm:w-min',
|
||||
prevLink && 'ml-auto',
|
||||
]}
|
||||
>
|
||||
{nextText || 'Next'}
|
||||
<IconChevronsRight class="ml-2 text-xl size-5" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import type { FrontmatterImage } from '~/types'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
interface Props {
|
||||
avatarImage?: FrontmatterImage
|
||||
}
|
||||
const { avatarImage } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-around sm:gap-5 max-w-full mb-6 py-10 bg-accent/8 p-4 rounded-xl"
|
||||
>
|
||||
{
|
||||
avatarImage && (
|
||||
<Image
|
||||
src={avatarImage.src}
|
||||
alt={avatarImage.alt}
|
||||
class="rounded-full border-8 border-accent/15 size-36 aspect-square box-content shrink-0"
|
||||
widths={[288]}
|
||||
height={144}
|
||||
width={144}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<div class:list={['m-4 max-w-full prose']}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { dateString, SeriesGroup } from '~/utils'
|
||||
import { render, type CollectionEntry } from 'astro:content'
|
||||
import type { Collation } from '~/types'
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { post, class: className } = Astro.props
|
||||
const { remarkPluginFrontmatter } = await render(post)
|
||||
const { minutesRead } = remarkPluginFrontmatter
|
||||
const seriesFrontmatter = post.data.series
|
||||
let series: Collation<'posts'> | undefined
|
||||
let seriesPostNumber: number | undefined
|
||||
let seriesTotal: number | undefined
|
||||
if (seriesFrontmatter) {
|
||||
const seriesGroup = await SeriesGroup.build()
|
||||
series = seriesGroup.match(seriesFrontmatter)
|
||||
if (!series) {
|
||||
// This should never happen if the series data is correct
|
||||
throw new Error(`Series "${seriesFrontmatter}" not found`)
|
||||
}
|
||||
seriesPostNumber = series.entries.findIndex((p) => p.id === post.id) + 1
|
||||
seriesTotal = series.entries.length
|
||||
}
|
||||
---
|
||||
|
||||
<div class:list={[className, 'text-foreground/80']}>
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:gap-2 sm:flex-wrap items-start sm:items-center sm:flex-row"
|
||||
>
|
||||
{
|
||||
series && seriesPostNumber && seriesTotal && (
|
||||
<a
|
||||
class="shrink-0 rounded-2xl py-[3px] px-3.5 bg-foreground/1 me-2.5 hover:bg-foreground/5 transition-colors border-1 border-foreground/16 hover:border-foreground/20"
|
||||
href={series.url}
|
||||
>
|
||||
{series.title} {seriesPostNumber} / {seriesTotal}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<div class="shrink-0 pl-0.5 text-[17px] sm:text-base">
|
||||
<time>{dateString(post.data.published)}</time>
|
||||
{
|
||||
post.data.author && (
|
||||
<span class="before:content-['·'] before:font-bold before:inline-block before:mx-0.5">
|
||||
{post.data.author}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minutesRead && (
|
||||
<span class="before:content-['·'] before:inline-block before:mx-0.5">
|
||||
{minutesRead}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import IconChevronsRight from '~/icons/chevrons-right.svg'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import { render } from 'astro:content'
|
||||
import Tags from '~/components/Tags.astro'
|
||||
import PostInfo from '~/components/PostInfo.astro'
|
||||
import type { Collation } from '~/types'
|
||||
import { TagsGroup } from '~/utils'
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>
|
||||
}
|
||||
|
||||
const { post } = Astro.props
|
||||
|
||||
const { remarkPluginFrontmatter } = await render(post)
|
||||
const description = remarkPluginFrontmatter.description || post.data.description
|
||||
let tags: Collation<'posts'>[] | undefined
|
||||
if (post.data.tags && post.data.tags.length > 0) {
|
||||
const tagsGroup = await TagsGroup.build()
|
||||
tags = tagsGroup.matchMany(post.data.tags)
|
||||
}
|
||||
const samePage = Astro.url.pathname === `/posts/${post.id}`
|
||||
const articleLink = samePage ? `#${post.id}` : `/posts/${post.id}`
|
||||
---
|
||||
|
||||
<article class="w-full py-5 my-1 md:my-4 border-accent/10">
|
||||
<h1 class="mb-3 text-2xl text-heading1 font-semibold">
|
||||
<a href={articleLink}>{post.data.title}</a>
|
||||
</h1>
|
||||
<PostInfo post={post} class="mb-3 mt-4" />
|
||||
{description && <p class="pl-0.5 my-4 text-base/7 text-foreground">{description}</p>}
|
||||
{
|
||||
tags && (
|
||||
<div class="mb-6">
|
||||
<Tags tags={tags} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<a class="button flex items-center !pl-3.5" href={articleLink}>
|
||||
{samePage ? 'Continue' : 'Read'}
|
||||
<IconChevronsRight class="size-5 ml-2" />
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
import IconChevronUp from '~/icons/chevron-up.svg'
|
||||
---
|
||||
|
||||
<button
|
||||
hidden
|
||||
class="scroll-up size-11 fixed flex items-center justify-center bg-background z-100 bottom-3 right-3 rounded-full border-2 border-accent/20 hover:bg-accent/10 md:size-12 md:bottom-5 md:right-5 lg:size-13 lg:bottom-6 lg:right-6"
|
||||
>
|
||||
<IconChevronUp class="text-accent/50 size-8 lg:size-9" />
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function transitionHiddenElement(
|
||||
element: HTMLElement,
|
||||
transitionProperty: string,
|
||||
transitionClass: string,
|
||||
) {
|
||||
const listener = (e: TransitionEvent) => {
|
||||
if (e.target === element && e.propertyName === transitionProperty) {
|
||||
element.setAttribute('hidden', 'true')
|
||||
element.removeEventListener('transitionend', listener)
|
||||
}
|
||||
}
|
||||
return {
|
||||
show() {
|
||||
element.removeEventListener('transitionend', listener)
|
||||
element.removeAttribute('hidden')
|
||||
// Force a browser re-paint
|
||||
const _reflow = element.offsetHeight
|
||||
element.classList.add(transitionClass)
|
||||
},
|
||||
hide() {
|
||||
element.addEventListener('transitionend', listener)
|
||||
element.classList.remove(transitionClass)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const button = document.querySelector('button.scroll-up') as HTMLButtonElement
|
||||
const topHeading = document.querySelector('h1')
|
||||
const topOffset = topHeading ? topHeading.offsetTop : 0
|
||||
if (!button) {
|
||||
console.warn('Scroll up button not found in the document.')
|
||||
return
|
||||
}
|
||||
button.addEventListener('click', () => {
|
||||
window.scrollTo({ top: topOffset, behavior: 'smooth' })
|
||||
})
|
||||
// Use IntersectionObserver to show/hide the button
|
||||
const prose = document.querySelector('article div.prose')
|
||||
if (!prose) {
|
||||
console.warn('Prose element not found in the article.')
|
||||
return
|
||||
}
|
||||
|
||||
const btnControl = transitionHiddenElement(button, 'opacity', 'active')
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
btnControl.show()
|
||||
} else {
|
||||
btnControl.hide()
|
||||
}
|
||||
})
|
||||
},
|
||||
// Only consider the top 5% of the viewport
|
||||
{ rootMargin: `0px 0px -95% 0px` },
|
||||
)
|
||||
observer.observe(prose)
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* When button appears, slide it in from the bottom */
|
||||
button.scroll-up {
|
||||
/* also add a translate effect */
|
||||
transform: translateY(0px);
|
||||
/* and a transition */
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.3s ease-in-out,
|
||||
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
button.scroll-up:not(.active) {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
import '@pagefind/default-ui/css/ui.css'
|
||||
import IconCircleX from '~/icons/circle-x.svg'
|
||||
import IconSearch from '~/icons/search.svg'
|
||||
|
||||
const { trailingSlashes = false } = Astro.props
|
||||
---
|
||||
|
||||
<site-search class="ms-auto" id="search" data-trailing-slashes={trailingSlashes}>
|
||||
<button
|
||||
class="hover:text-accent flex cursor-pointer items-center justify-center rounded-md"
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
data-open-modal
|
||||
disabled
|
||||
>
|
||||
<IconSearch class="size-6 text-accent" />
|
||||
<span class="sr-only">Open Search</span>
|
||||
</button>
|
||||
<dialog
|
||||
aria-label="search"
|
||||
class="text-foreground! bg-background max-h-5/6 min-h-48 w-7/8 sm:w-5/6 max-w-xl border-double! border-4 border-accent/30 shadow-sm backdrop:backdrop-blur-sm open:flex mx-auto mt-10 sm:mt-16 mb-auto rounded-xl"
|
||||
>
|
||||
<div class="dialog-frame flex grow flex-col gap-4 p-6 pt-6 max-w-full">
|
||||
<button class="cursor-pointer fixed top-2 right-2 rounded-full" data-close-modal>
|
||||
<IconCircleX class="size-6 text-accent/50" />
|
||||
</button>
|
||||
{
|
||||
import.meta.env.DEV ? (
|
||||
<div class="mx-auto text-center">
|
||||
<p>
|
||||
Search is only available in production builds. <br />
|
||||
Try building and previewing the site to test it out locally.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="search-container">
|
||||
<div id="pagefind-search" class="max-w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</dialog>
|
||||
</site-search>
|
||||
|
||||
<style is:global>
|
||||
:root {
|
||||
--pagefind-ui-font: inherit;
|
||||
--pagefind-ui-primary: var(--theme-accent);
|
||||
--pagefind-ui-text: var(--theme-foreground);
|
||||
--pagefind-ui-background: var(--theme-background);
|
||||
--pagefind-ui-border: var(--theme-accent);
|
||||
--pagefind-ui-border-width: 2px;
|
||||
}
|
||||
|
||||
.pagefind-ui__results-area {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.pagefind-ui__result {
|
||||
overflow-x: scroll !important;
|
||||
}
|
||||
|
||||
.pagefind-ui__result-inner {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.pagefind-ui__result-inner mark {
|
||||
background-color: var(--theme-accent) !important;
|
||||
color: var(--theme-background) !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 3px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.pagefind-ui__result-nested .pagefind-ui__result-link:before {
|
||||
content: '>' !important;
|
||||
left: -16px !important;
|
||||
right: unset !important;
|
||||
color: var(--theme-accent) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class SiteSearch extends HTMLElement {
|
||||
#closeBtn: HTMLButtonElement | null
|
||||
#dialog: HTMLDialogElement | null
|
||||
#dialogFrame: HTMLDivElement | null
|
||||
#openBtn: HTMLButtonElement | null
|
||||
#controller: AbortController
|
||||
trailingSlashes: boolean
|
||||
stripTrailingSlash: (path: string) => string
|
||||
formatURL: (path: string) => string
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.#openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')
|
||||
this.#closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')
|
||||
this.#dialog = this.querySelector<HTMLDialogElement>('dialog')
|
||||
this.#dialogFrame = this.querySelector('.dialog-frame')
|
||||
this.#controller = new AbortController()
|
||||
|
||||
this.trailingSlashes = this.dataset.trailingSlashes === 'true' ? true : false
|
||||
this.stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2')
|
||||
this.formatURL = !this.trailingSlashes
|
||||
? this.stripTrailingSlash
|
||||
: (path: string) => path
|
||||
|
||||
// Set up events
|
||||
if (this.#openBtn) {
|
||||
this.#openBtn.addEventListener('click', this.openModal)
|
||||
this.#openBtn.disabled = false
|
||||
} else {
|
||||
console.warn('Search button not found')
|
||||
}
|
||||
|
||||
if (this.#closeBtn) {
|
||||
this.#closeBtn.addEventListener('click', this.closeModal)
|
||||
} else {
|
||||
console.warn('Close button not found')
|
||||
}
|
||||
|
||||
if (this.#dialog) {
|
||||
this.#dialog.addEventListener('close', () => {
|
||||
window.removeEventListener('click', this.onWindowClick)
|
||||
const body = document.querySelector('body')
|
||||
body?.classList.remove('overflow-hidden')
|
||||
})
|
||||
} else {
|
||||
console.warn('Dialog not found')
|
||||
}
|
||||
|
||||
// only add pagefind in production
|
||||
if (import.meta.env.DEV) return
|
||||
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1))
|
||||
onIdle(async () => {
|
||||
const { PagefindUI } = await import('@pagefind/default-ui')
|
||||
|
||||
new PagefindUI({
|
||||
baseUrl: import.meta.env.BASE_URL,
|
||||
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
|
||||
element: '#pagefind-search',
|
||||
showImages: false,
|
||||
showSubResults: true,
|
||||
processResult: (result: {
|
||||
url: string
|
||||
sub_results: Array<{ url: string }>
|
||||
}) => {
|
||||
// Ensure links in search results match the trailing slashes setting
|
||||
result.url = this.formatURL(result.url)
|
||||
|
||||
result.sub_results = result.sub_results.map((res) => {
|
||||
res.url = this.formatURL(res.url)
|
||||
return res
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// window events, requires cleanup
|
||||
window.addEventListener('keydown', this.onWindowKeydown, {
|
||||
signal: this.#controller.signal,
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#controller.abort()
|
||||
}
|
||||
|
||||
openModal = (event?: MouseEvent) => {
|
||||
if (!this.#dialog) {
|
||||
console.warn('Dialog not found')
|
||||
return
|
||||
}
|
||||
|
||||
const body = document.querySelector('body')
|
||||
body?.classList.add('overflow-hidden')
|
||||
|
||||
this.#dialog.showModal()
|
||||
this.querySelector('input')?.focus()
|
||||
event?.stopPropagation()
|
||||
window.addEventListener('click', this.onWindowClick, {
|
||||
signal: this.#controller.signal,
|
||||
})
|
||||
}
|
||||
|
||||
closeModal = () => {
|
||||
this.#dialog?.close()
|
||||
}
|
||||
|
||||
onWindowClick = (event: MouseEvent) => {
|
||||
// check if it's a link
|
||||
const isLink = 'href' in (event.target || {})
|
||||
// make sure the click is either a link or outside of the dialog
|
||||
if (
|
||||
isLink ||
|
||||
(document.body.contains(event.target as Node) &&
|
||||
!this.#dialogFrame?.contains(event.target as Node))
|
||||
) {
|
||||
this.closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
onWindowKeydown = (e: KeyboardEvent) => {
|
||||
if (!this.#dialog) {
|
||||
console.warn('Dialog not found')
|
||||
return
|
||||
}
|
||||
// check if it's the Control+K or ⌘+K shortcut
|
||||
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
|
||||
this.#dialog.open ? this.closeModal() : this.openModal()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('site-search', SiteSearch)
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
import '@pagefind/default-ui/css/ui.css'
|
||||
import IconPalette from '~/icons/palette.svg'
|
||||
import IconCircleX from '~/icons/circle-x.svg'
|
||||
import siteConfig from '~/site.config'
|
||||
|
||||
function kebabToTitleCase(str: string): string {
|
||||
return str
|
||||
.split('-') // Split the string into words
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) // Capitalize each word
|
||||
.join(' ') // Join the words with a space
|
||||
}
|
||||
---
|
||||
|
||||
<select-theme class="ms-auto" id="search">
|
||||
<button
|
||||
class="hover:text-accent flex cursor-pointer items-center justify-center rounded-md"
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
data-open-modal
|
||||
disabled
|
||||
>
|
||||
<IconPalette class="size-6 text-accent" />
|
||||
<span class="sr-only">Select Theme</span>
|
||||
</button>
|
||||
<dialog
|
||||
aria-label="select-theme"
|
||||
class="text-foreground! bg-background max-h-5/6 max-w-5/6 border-double! border-4 border-accent/30 shadow-sm backdrop:backdrop-blur-sm open:flex mx-auto mt-16 mb-auto rounded-xl"
|
||||
>
|
||||
<div class="dialog-frame flex grow flex-col gap-4 px-10 py-1 max-w-full">
|
||||
<button
|
||||
aria-roledescription="close"
|
||||
class="cursor-pointer fixed top-2 right-2 rounded-full"
|
||||
data-close-modal
|
||||
>
|
||||
<IconCircleX class="size-6 text-accent/50" />
|
||||
</button>
|
||||
<ul
|
||||
id="theme-change-list"
|
||||
class="flex flex-col bg-background text-accent m-0 p-2 rounded-xl"
|
||||
>
|
||||
{
|
||||
siteConfig.themes.include.map((theme) => (
|
||||
<li>
|
||||
<button class="w-full rounded-lg py-1 px-2" data-theme={theme}>
|
||||
{kebabToTitleCase(theme)}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
</select-theme>
|
||||
|
||||
<style>
|
||||
button.current-theme {
|
||||
background-color: color-mix(in srgb, var(--theme-accent) 8%, transparent 92%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class SelectTheme extends HTMLElement {
|
||||
#closeBtn: HTMLButtonElement | null
|
||||
#dialog: HTMLDialogElement | null
|
||||
#dialogFrame: HTMLDivElement | null
|
||||
#openBtn: HTMLButtonElement | null
|
||||
#controller: AbortController
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.#openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')
|
||||
this.#closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')
|
||||
this.#dialog = this.querySelector<HTMLDialogElement>('dialog')
|
||||
this.#dialogFrame = this.querySelector('.dialog-frame')
|
||||
this.#controller = new AbortController()
|
||||
|
||||
// Set up events
|
||||
if (this.#openBtn) {
|
||||
this.#openBtn.addEventListener('click', this.openModal)
|
||||
this.#openBtn.disabled = false
|
||||
} else {
|
||||
console.warn('Select theme button not found')
|
||||
}
|
||||
|
||||
if (this.#closeBtn) {
|
||||
this.#closeBtn.addEventListener('click', this.closeModal)
|
||||
} else {
|
||||
console.warn('Close button not found')
|
||||
}
|
||||
|
||||
if (this.#dialog) {
|
||||
this.#dialog.addEventListener('close', () => {
|
||||
window.removeEventListener('click', this.onWindowClick)
|
||||
const body = document.querySelector('body')
|
||||
body?.classList.remove('overflow-hidden')
|
||||
})
|
||||
} else {
|
||||
console.warn('Dialog not found')
|
||||
}
|
||||
|
||||
let themeChangeButtons = this.querySelectorAll('#theme-change-list button')
|
||||
themeChangeButtons?.forEach((button) => {
|
||||
button.addEventListener('click', (ev) => {
|
||||
ev.preventDefault()
|
||||
let themeId = button.getAttribute('data-theme')
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute('data-theme', themeId)
|
||||
localStorage.setItem('data-theme', themeId)
|
||||
this.closeModal()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// window events, requires cleanup
|
||||
window.addEventListener('keydown', this.onWindowKeydown, {
|
||||
signal: this.#controller.signal,
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#controller.abort()
|
||||
}
|
||||
|
||||
openModal = (event?: MouseEvent) => {
|
||||
if (!this.#dialog) {
|
||||
console.warn('Dialog not found')
|
||||
return
|
||||
}
|
||||
this.highlightCurrentTheme()
|
||||
const body = document.querySelector('body')
|
||||
body?.classList.add('overflow-hidden')
|
||||
this.#dialog.showModal()
|
||||
event?.stopPropagation()
|
||||
window.addEventListener('click', this.onWindowClick, {
|
||||
signal: this.#controller.signal,
|
||||
})
|
||||
}
|
||||
|
||||
closeModal = () => {
|
||||
this.#dialog?.close()
|
||||
}
|
||||
|
||||
onWindowClick = (event: MouseEvent) => {
|
||||
// check if it's a link
|
||||
const isLink = 'href' in (event.target || {})
|
||||
// make sure the click is either a link or outside of the dialog
|
||||
if (
|
||||
isLink ||
|
||||
(document.body.contains(event.target as Node) &&
|
||||
!this.#dialogFrame?.contains(event.target as Node))
|
||||
) {
|
||||
this.closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
onWindowKeydown = (e: KeyboardEvent) => {
|
||||
if (!this.#dialog) {
|
||||
console.warn('Dialog not found')
|
||||
return
|
||||
}
|
||||
// check if it's the Control+K or ⌘+K shortcut
|
||||
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
|
||||
this.#dialog.open ? this.closeModal() : this.openModal()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
highlightCurrentTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme')
|
||||
if (!currentTheme) {
|
||||
console.warn('No theme set in data-theme attribute.')
|
||||
return
|
||||
}
|
||||
const themeChangeListItems = this.querySelectorAll('#theme-change-list li')
|
||||
themeChangeListItems?.forEach((listItem) => {
|
||||
let button = listItem.querySelector('button')
|
||||
if (!button) {
|
||||
console.warn('No button found in theme change list item.')
|
||||
return
|
||||
}
|
||||
if (button.getAttribute('data-theme') === currentTheme) {
|
||||
button.classList.add('current-theme')
|
||||
} else {
|
||||
button.classList.remove('current-theme')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('select-theme', SelectTheme)
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
;(function loadTheme() {
|
||||
const pageDefaultTheme = document.documentElement.getAttribute('data-theme')
|
||||
const pageThemeHash = document.documentElement.getAttribute('data-theme-hash')
|
||||
if (!pageDefaultTheme || !pageThemeHash) {
|
||||
throw new Error('Theme attributes are required.')
|
||||
}
|
||||
const storedTheme = localStorage.getItem('data-theme')
|
||||
const storedThemeHash = localStorage.getItem('data-theme-hash')
|
||||
const themeHashMatches = storedThemeHash === pageThemeHash
|
||||
if (!storedTheme || !storedThemeHash || !themeHashMatches) {
|
||||
// Should be the first time loading the website
|
||||
localStorage.setItem('data-theme', pageDefaultTheme)
|
||||
localStorage.setItem('data-theme-hash', pageThemeHash)
|
||||
}
|
||||
if (themeHashMatches && storedTheme && storedTheme !== pageDefaultTheme) {
|
||||
// The stored theme is different from the default theme, apply it
|
||||
document.documentElement.setAttribute('data-theme', storedTheme)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import { SeriesGroup } from '~/utils'
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[]
|
||||
}
|
||||
|
||||
const { posts } = Astro.props
|
||||
|
||||
const seriesGroup = await SeriesGroup.build(posts)
|
||||
---
|
||||
|
||||
<ul class="flex flex-wrap gap-x-3 gap-y-4 my-5">
|
||||
{
|
||||
seriesGroup.sortCollationsMostRecent().map((series) => (
|
||||
<li>
|
||||
<a
|
||||
href={series.url}
|
||||
class="flex items-start gap-2.5 py-2 pl-4 pr-3 bg-accent/7 rounded-3xl border-transparent hover:border-accent/20 border-1 text-foreground/90"
|
||||
>
|
||||
{series.title}
|
||||
<span class="rounded-full bg-foreground/7 text-foreground/90 px-2 py-1 text-xs font-semibold">
|
||||
{series.entries.length}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
import type { SocialLinks } from '~/types'
|
||||
import IconGithub from '~/icons/github.svg'
|
||||
import IconMastodon from '~/icons/mastodon.svg'
|
||||
import IconTwitter from '~/icons/twitter.svg'
|
||||
import IconLinkedin from '~/icons/linkedin.svg'
|
||||
import IconBluesky from '~/icons/bluesky.svg'
|
||||
import IconEmail from '~/icons/email.svg'
|
||||
import IconRss from '~/icons/rss.svg'
|
||||
|
||||
interface Props {
|
||||
socialLinks: SocialLinks
|
||||
}
|
||||
|
||||
const { socialLinks } = Astro.props
|
||||
---
|
||||
|
||||
<div class="flex flex-wrap gap-2 items-center justify-center">
|
||||
{
|
||||
socialLinks.github && (
|
||||
<a
|
||||
href={socialLinks.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<span class="sr-only">GitHub</span>
|
||||
<IconGithub class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
socialLinks.mastodon && (
|
||||
<a
|
||||
href={socialLinks.mastodon}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="Mastodon"
|
||||
>
|
||||
<span class="sr-only">Mastodon</span>
|
||||
<IconMastodon class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
socialLinks.bluesky && (
|
||||
<a
|
||||
href={socialLinks.bluesky}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="Bluesky"
|
||||
>
|
||||
<span class="sr-only">Bluesky</span>
|
||||
<IconBluesky class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
socialLinks.twitter && (
|
||||
<a
|
||||
href={socialLinks.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<span class="sr-only">Twitter</span>
|
||||
<IconTwitter class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
socialLinks.linkedin && (
|
||||
<a
|
||||
href={socialLinks.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<span class="sr-only">LinkedIn</span>
|
||||
<IconLinkedin class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
socialLinks.email && (
|
||||
<a
|
||||
href={`mailto:${socialLinks.email}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="Email"
|
||||
>
|
||||
<span class="sr-only">Email</span>
|
||||
<IconEmail class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
socialLinks.rss && (
|
||||
<a
|
||||
href="/rss.xml"
|
||||
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
|
||||
aria-label="RSS Feed"
|
||||
>
|
||||
<span class="sr-only">RSS</span>
|
||||
<IconRss class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import type { MarkdownHeading } from 'astro'
|
||||
import TOCHeading from '~/components/TableOfContentsHeading.astro'
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[]
|
||||
}
|
||||
|
||||
const { headings } = Astro.props
|
||||
|
||||
const filteredHeadings = headings.filter(({ depth }) => depth >= 2 && depth <= 3)
|
||||
---
|
||||
|
||||
<details
|
||||
open
|
||||
class="toc relative md:mx-2 xl:mx-0 text-foreground/90 text-sm bg-foreground/5 xl:bg-transparent px-8 xl:pr-0 py-6 mt-5 xl:mt-0 xl:w-full rounded-xl border-3 xl:border-none border-accent/10 xl:sticky xl:top-10 xl:basis-[274px] 2xl:basis-[320px] xl:order-2 xl:shrink-0 xl:opacity-90"
|
||||
>
|
||||
<summary
|
||||
class="list-none marker:hidden marker:content-[''] before:content-['>'] before:text-accent before:font-semibold before:absolute before:left-3 cursor-pointer"
|
||||
>Table of Contents</summary
|
||||
>
|
||||
<nav class="w-full text-sm xl:-ml-1">
|
||||
<ul class="mt-4 flex flex-col max-w-full">
|
||||
{filteredHeadings.map((heading) => <TOCHeading heading={heading} />)}
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
const anchors = document.querySelectorAll('h2[id], h3[id]')
|
||||
const links = document.querySelectorAll('details.toc > nav > ul > li')
|
||||
if (anchors.length === links.length && anchors.length > 0) {
|
||||
window.addEventListener('scroll', (_event) => {
|
||||
let scrollTop = window.scrollY
|
||||
let highlighted = false
|
||||
// then iterate backwards, on the first match highlight it and break
|
||||
for (var i = anchors.length - 1; i >= 0; i--) {
|
||||
const anchor = anchors[i] as HTMLElement
|
||||
if (!highlighted && scrollTop > anchor.offsetTop - 75) {
|
||||
links[i].classList.add('active-heading')
|
||||
highlighted = true
|
||||
} else {
|
||||
links[i].classList.remove('active-heading')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
details[open] summary:before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import type { MarkdownHeading } from 'astro'
|
||||
|
||||
interface Props {
|
||||
heading: MarkdownHeading
|
||||
}
|
||||
|
||||
const {
|
||||
heading: { depth, slug, text },
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<li class="flex items-stretch border-foreground/20">
|
||||
<div class="flex items-stretch">
|
||||
{
|
||||
Array.from({ length: depth - 1 }, (_v, _k) => (
|
||||
<div class="toc-lines flex flex-col items-stretch">
|
||||
<span class="top-box w-4 xl:w-3 basis-4 border-b-1 border-foreground/20 xl:border-foreground/15" />
|
||||
<span class="bottom-box w-4 min-h-1/2 xl:w-3 flex-1 border-t-1 border-foreground/20 xl:border-foreground/15" />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<a
|
||||
class="inline-block line-clamp-2 ml-3 xl:ml-2 py-1 ps-1 hover:text-accent"
|
||||
href={`#${slug}`}>{text}</a
|
||||
>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.toc-lines:first-child span {
|
||||
border-left-width: 2px;
|
||||
}
|
||||
li:last-child .toc-lines:first-child span.bottom-box {
|
||||
border-left-width: 0 !important;
|
||||
}
|
||||
li.active-heading a {
|
||||
color: var(--theme-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import type { Collation } from '~/types'
|
||||
|
||||
interface Props {
|
||||
tags: Collation<'posts'>[]
|
||||
}
|
||||
|
||||
const { tags } = Astro.props
|
||||
---
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
{
|
||||
tags.map((tag) => (
|
||||
<a
|
||||
href={tag.url}
|
||||
class="py-1 px-3 bg-accent/1 hover:bg-accent/8 border-1 border-accent/20 text-accent/90 rounded-2xl transition-colors"
|
||||
>
|
||||
{tag.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import { TagsGroup } from '~/utils'
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[]
|
||||
}
|
||||
|
||||
const { posts } = Astro.props
|
||||
const tagsGroup = await TagsGroup.build(posts)
|
||||
---
|
||||
|
||||
<ul class="flex flex-wrap gap-x-3 gap-y-4 my-5">
|
||||
{
|
||||
tagsGroup.sortCollationsLargest().map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
href={tag.url}
|
||||
class="flex items-start gap-2.5 py-2 pl-4 pr-3 bg-accent/7 rounded-3xl border-transparent hover:border-accent/20 border-1 text-foreground/90"
|
||||
>
|
||||
{tag.title}
|
||||
<span class="rounded-full bg-foreground/7 text-foreground/90 px-2 py-1 text-xs font-semibold">
|
||||
{tag.entries.length}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineCollection, z } from 'astro:content'
|
||||
import { glob } from 'astro/loaders'
|
||||
|
||||
const postsCollection = defineCollection({
|
||||
loader: glob({ pattern: ['**/*.md', '**/*.mdx'], base: './src/content/posts' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
published: z.coerce.date(),
|
||||
// updated: z.coerce.date().optional(),
|
||||
draft: z.boolean().optional().default(false),
|
||||
description: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
series: z.string().optional(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
coverImage: z
|
||||
.strictObject({
|
||||
src: image(),
|
||||
alt: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
toc: z.boolean().optional().default(true),
|
||||
}),
|
||||
})
|
||||
|
||||
const homeCollection = defineCollection({
|
||||
loader: glob({ pattern: ['home.md', 'home.mdx'], base: './src/content' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
avatarImage: z
|
||||
.object({
|
||||
src: image(),
|
||||
alt: z.string().optional().default('My avatar'),
|
||||
})
|
||||
.optional(),
|
||||
githubCalendar: z.string().optional(), // GitHub username for calendar
|
||||
}),
|
||||
})
|
||||
|
||||
const addendumCollection = defineCollection({
|
||||
loader: glob({ pattern: ['addendum.md', 'addendum.mdx'], base: './src/content' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
avatarImage: z
|
||||
.object({
|
||||
src: image(),
|
||||
alt: z.string().optional().default('My avatar'),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const legalCollection = defineCollection({
|
||||
loader: glob({ pattern: ['**/*.md', '**/*.mdx'], base: './src/content/legal' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
updated: z.coerce.date(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const collections = {
|
||||
posts: postsCollection,
|
||||
home: homeCollection,
|
||||
addendum: addendumCollection,
|
||||
legal: legalCollection,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
avatarImage:
|
||||
src: './avatar.jpeg'
|
||||
alt: 'Michael Kinder'
|
||||
---
|
||||
|
||||
Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.
|
||||
|
After Width: | Height: | Size: 238 KiB |
@@ -0,0 +1,8 @@
|
||||
---
|
||||
avatarImage:
|
||||
src: './avatar.jpeg'
|
||||
alt: 'Michael Kinder'
|
||||
githubCalendar: 'foggymtndrifter'
|
||||
---
|
||||
|
||||
I'm a mountain-dwelling Linux specialist and open source contributor. When I'm not coding, you'll find me exploring the Appalachian trails or automating mountain life. Welcome to my digital cabin — grab some coffee and stay awhile.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Privacy Policy
|
||||
updated: 2026-01-30
|
||||
---
|
||||
|
||||
# Privacy Policy
|
||||
|
||||
Look, I'm a normie, not a data broker. I built this site to share stuff, not to harvest your personal details. But, for the sake of transparency (and because the internet requires it), here is exactly what is happening with your data.
|
||||
|
||||
## 1. The "Who Are You?" Part (Analytics)
|
||||
|
||||
I use **Umami** for analytics. Ideally, I'd like to know if anyone is actually reading my blog posts or if I'm just shouting into the void.
|
||||
- **No Cookies:** Umami doesn't use cookies to track you.
|
||||
- **No Personal Data:** It doesn't collect your IP address or fingerprint your device.
|
||||
- **Just Numbers:** It just tells me "Hey, someone visited from the internet." That's it.
|
||||
|
||||
## 2. The "Yelling at Me" Part (Comments)
|
||||
|
||||
I use GitHub Discussions to power my comments section. When you post a comment, you are interacting directly with the GitHub API. Please refer to GitHub's Privacy Policy for more information on how they handle your data. This means I don't store your comments in a database; GitHub does.
|
||||
|
||||
- If you want to know what GitHub does with your data, you'll have to read [their privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement). I assume it involves storing your comment.
|
||||
|
||||
## 3. The "Free Coffee" Part (Donations)
|
||||
|
||||
If you are generous enough to buy me a coffee via the "sudo buy-coffee" modal:
|
||||
- **Stripe** handles all the heavy lifting.
|
||||
- I **never** see your credit card number. It goes straight to Stripe's secure vault.
|
||||
- Stripe might collect your email or name to send you a receipt, but that's between you and them.
|
||||
- You can read Stripe's privacy policy [here](https://stripe.com/privacy) if you enjoy reading legal documents.
|
||||
|
||||
## 4. Cookies (The Digital Kind)
|
||||
|
||||
I prefer the oatmeal raisin variety. This site itself is pretty much cookie-free.
|
||||
- **However:** Third-party services like **Stripe** (for payments) and **GitHub** (for comments) might verify you are human (or logged in) using cookies.
|
||||
- I have no control over those, but they are generally essential for security and functionality.
|
||||
|
||||
## 5. Your Rights
|
||||
|
||||
You have the right to remain silent... wait, wrong script.
|
||||
You have the right to access, correct, or delete your data. Since I generally don't *have* your data (it's with Stripe or GitHub), you'll likely need to contact them directly. But if you think I have something of yours, feel free to reach out.
|
||||
|
||||
## 6. Changes
|
||||
|
||||
I might update this page if I add new features. I'll change the date below if I do.
|
||||
|
||||
## 7. Contact
|
||||
|
||||
If you have questions, [email me](mailto:michael@foggymtndrifter.com) or send a carrier pigeon.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Terms of Use
|
||||
updated: 2026-01-28
|
||||
---
|
||||
|
||||
# Terms of Use
|
||||
|
||||
Welcome to **foggymtndrifter.com**. By hanging out here, you agree to these terms. If you don't agree, you can simply close the tab (but I'll be sad to see you go).
|
||||
|
||||
## 1. The Gist
|
||||
|
||||
This is a personal blog. I write code, I write opinions, and sometimes I write bugs. Use the site as intended, don't try to hack it, and we'll get along fine.
|
||||
|
||||
## 2. Comments (Be Nice)
|
||||
|
||||
Comments are hosted via **GitHub Discussions**.
|
||||
- **Don't be a jerk.** No hate speech, harassment, or spam.
|
||||
- **I have the ban hammer.** I reserve the right to hide or delete comments that violate common decency (or just really annoy me).
|
||||
- **Your words are yours.** You own your comments, but by posting them here, you let me display them.
|
||||
|
||||
## 3. Money Stuff (Donations)
|
||||
|
||||
If you use the donation modal:
|
||||
- **Thank you!** You are awesome.
|
||||
- **No Refunds.** Please treat donations like dropping a tip in a jar. Once it's in, it's coffee money. I can't easily process refunds, so please double-check the amount.
|
||||
- **Security.** Payments are processed by Stripe. I don't touch your financial data.
|
||||
|
||||
## 4. Code & Content
|
||||
|
||||
- **My Stuff:** The text and images are mine unless noted otherwise. Please don't scrape the whole site and repost it as your own.
|
||||
- **Code Snippets:** Feel free to use code snippets from my blog posts in your own projects. That's why they are there. A link back is appreciated but not required.
|
||||
- **No Warranty:** If you copy-paste code from here and it crashes your production server, that is unfortunate, but it is not my fault. **Always** review code before running it.
|
||||
|
||||
## 5. Third Parties
|
||||
|
||||
I link to other sites (like GitHub, Stripe, or random cool blogs). I'm not responsible for what happens on those sites.
|
||||
|
||||
## 6. Liability (The "Don't Sue Me" Clause)
|
||||
|
||||
This site is provided "as is." I try my best to keep it running and accurate, but I make no promises. I am not liable for any damages (digital, emotional, or otherwise) resulting from your use of this site.
|
||||
|
||||
## 7. Changes
|
||||
|
||||
I can change these terms whenever. If you keep using the site, you accept the new terms.
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: "Building a Custom Comment System with GitHub Discussions"
|
||||
published: 2026-01-29
|
||||
draft: false
|
||||
description: "How I replaced Giscus with a custom comment system powered by GitHub Discussions, complete with guest comments, OAuth, and spam protection."
|
||||
tags: ['Astro', 'Svelte', 'GitHub API', 'Web Development']
|
||||
---
|
||||
|
||||
I recently decided to replace Giscus on my blog with a custom-built comment system. While Giscus is fantastic, I wanted more control over the user experience and the ability to support guest comments without requiring GitHub authentication. Here's how I built it.
|
||||
|
||||
## The Goal
|
||||
|
||||
I wanted a comment system that:
|
||||
- Uses GitHub Discussions as the backend (free, reliable, and I already use GitHub)
|
||||
- Supports both authenticated GitHub users and anonymous guests
|
||||
- Has a clean UI that matches my site's aesthetic
|
||||
- Includes basic spam protection (honeypot + CAPTCHA)
|
||||
- Sorts comments newest-first
|
||||
|
||||
## The Architecture
|
||||
|
||||
The system has three main components:
|
||||
|
||||
1. **Backend API routes** (Astro SSR endpoints)
|
||||
2. **Frontend component** (Svelte for reactivity)
|
||||
3. **OAuth flow** (GitHub authentication)
|
||||
|
||||
### Backend: API Routes
|
||||
|
||||
I created several API routes in `src/pages/api/`:
|
||||
|
||||
#### `/api/comments/[slug].ts`
|
||||
|
||||
This is the workhorse. It handles both fetching and posting comments.
|
||||
|
||||
**GET**: Fetches all comments for a post by searching GitHub Discussions for a discussion matching the post's pathname.
|
||||
|
||||
```typescript
|
||||
const searchQuery = `repo:${siteConfig.comments.repo} in:title ${term}`
|
||||
const result = await octokit.graphql(query, { term: searchQuery })
|
||||
```
|
||||
|
||||
The key insight here: GitHub's search API is powerful. By searching for discussions with the post's pathname in the title, I can reliably find the right discussion.
|
||||
|
||||
**POST**: Creates a new comment. This is where it gets interesting:
|
||||
|
||||
- If the user is authenticated (has a GitHub token cookie), post as them
|
||||
- If not, post as a bot account and append `_(Posted by DisplayName)_` to the comment body
|
||||
- The GET endpoint strips this attribution and uses it to display the guest's name
|
||||
|
||||
#### OAuth Routes
|
||||
|
||||
Three simple routes handle GitHub authentication:
|
||||
|
||||
- `/api/auth/signin` - Redirects to GitHub OAuth
|
||||
- `/api/auth/callback` - Exchanges code for token, stores in HTTP-only cookie
|
||||
- `/api/auth/signout` - Clears the token cookie
|
||||
|
||||
### Frontend: Svelte Component
|
||||
|
||||
I chose Svelte for the comment UI because it's lightweight and has excellent reactivity. The component handles:
|
||||
|
||||
- Displaying comments with proper nesting (replies)
|
||||
- A collapsible comment form
|
||||
- Switching between "Post as Guest" and "Sign in with GitHub" modes
|
||||
- Client-side CAPTCHA validation for guests
|
||||
|
||||
```svelte
|
||||
{#if authMode === 'guest' && !user}
|
||||
<div>
|
||||
<label for="captcha">Human Check: What is {num1} + {num2}?</label>
|
||||
<input id="captcha" type="number" bind:value={captchaAnswer} required />
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Guest Comments: The Attribution Trick
|
||||
|
||||
The clever part about guest comments is how they're stored. Since GitHub Discussions doesn't natively support "guest" authors, I:
|
||||
|
||||
1. Post the comment using a bot account
|
||||
2. Append the guest's display name in a specific format: `_(Posted by Jane Doe)_`
|
||||
3. On fetch, parse this attribution and transform the comment object
|
||||
4. Strip the attribution from the HTML before displaying
|
||||
|
||||
This means the comment lives in GitHub Discussions, but appears to be from the guest user in my UI.
|
||||
|
||||
## Spam Protection
|
||||
|
||||
I implemented two layers of protection:
|
||||
|
||||
### Honeypot
|
||||
|
||||
A hidden field that bots might fill but humans won't:
|
||||
|
||||
```svelte
|
||||
<input type="text" name="website_honey" class="hidden" bind:value={honeyPot} />
|
||||
```
|
||||
|
||||
If this field has a value, the submission is rejected.
|
||||
|
||||
### Math CAPTCHA
|
||||
|
||||
For guest comments only, a simple math question:
|
||||
|
||||
```typescript
|
||||
if (!userToken) {
|
||||
if (parseInt(num1) + parseInt(num2) !== parseInt(answer)) {
|
||||
return new Response('Incorrect math answer', { status: 400 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It's basic, but effective against simple bots without annoying users with complex CAPTCHAs.
|
||||
|
||||
## UI Polish
|
||||
|
||||
I spent time making the UI feel native to my site:
|
||||
|
||||
- Generic avatars for guests (using DiceBear's initials API)
|
||||
- Changed "OWNER" badge to "ADMIN"
|
||||
- Newest-first sorting (reversed the GitHub API response)
|
||||
- Collapsible form (using native `<details>` element)
|
||||
- Matched the styling to my "Buy me a coffee" modal
|
||||
|
||||
## Configuration
|
||||
|
||||
The system is configured in `site.config.ts`:
|
||||
|
||||
```typescript
|
||||
comments: {
|
||||
repo: 'FoggyMtnDrifter/website',
|
||||
repoId: 'R_kgDORBPuRw',
|
||||
category: 'General',
|
||||
categoryId: 'DIC_kwDORBPuR84C1bYd',
|
||||
}
|
||||
```
|
||||
|
||||
You'll also need environment variables:
|
||||
|
||||
```bash
|
||||
GITHUB_CLIENT_ID=your_oauth_app_client_id
|
||||
GITHUB_CLIENT_SECRET=your_oauth_app_secret
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN=your_bot_token
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **GitHub's GraphQL API is powerful** - The search query approach works better than trying to filter discussions directly
|
||||
2. **HTTP-only cookies are your friend** - Storing OAuth tokens securely is critical
|
||||
3. **Simple spam protection works** - You don't need reCAPTCHA for a personal blog
|
||||
4. **Native HTML is underrated** - Using `<details>` for the collapsible form meant zero JavaScript for that feature
|
||||
|
||||
## The Result
|
||||
|
||||
I now have a comment system that:
|
||||
- Costs nothing (uses GitHub's free tier)
|
||||
- Supports both authenticated and guest users
|
||||
- Matches my site's aesthetic perfectly
|
||||
- Has basic spam protection
|
||||
- Gives me full control over the UX
|
||||
|
||||
The best part? All comments are stored in GitHub Discussions, so they're backed up, searchable, and I can manage them using GitHub's excellent moderation tools.
|
||||
|
||||
If you're building an Astro site and want more control than Giscus provides, this approach is definitely worth considering. The code is all on my [GitHub repo](https://github.com/FoggyMtnDrifter/website) if you want to dig deeper.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: "Integrating Gitea & Vercel"
|
||||
published: 2024-06-18
|
||||
draft: false
|
||||
description: 'I share my experience integrating the open source tool Gitea and the hosting platform Vercel for streamlined code management and deployment.'
|
||||
tags: ['Gitea', 'Vercel']
|
||||
---
|
||||
|
||||
I'm a strong supporter of open source software and find that the tools I choose strongly influence my development workflow. For managing my code, I use [Gitea](https://gitea.io/). It's a fantastic, open source version control platform that's lightweight and offers the features and security I need. One of the aspects I love is [Gitea Actions](https://docs.gitea.io/en-us/actions/), which makes it easy to streamline my deployment process to [Vercel](https://vercel.com).
|
||||
|
||||
## Why Gitea?
|
||||
|
||||
Gitea excels as a self-hosted, open source version control platform. If you like having flexibility and control over your development setup, it's a compelling alternative to larger, corporate-owned code hosting solutions. With Gitea Actions (which are compatible with GitHub Actions), I can tap into the rich ecosystem of Actions from the broader development community without sacrificing the benefits of an independent, open source platform.
|
||||
|
||||
## Streamlining Deployment with Vercel
|
||||
|
||||
Let's talk about how I use Gitea Actions for deployment. Here's my `preview.yaml` workflow that enables streamlined feedback cycles. Whenever I open a pull request to the main branch, this workflow triggers a preview deployment on Vercel. This lets me test and gather feedback before merging changes:
|
||||
|
||||
```yaml
|
||||
name: Vercel Preview Deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
Deploy-Preview:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ">=18.14.1"
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
- name: Pull Vercel Environment Information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
echo "::group::Deploying"
|
||||
DEPLOY_OUTPUT=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} 2>&1)
|
||||
echo "$DEPLOY_OUTPUT"
|
||||
echo "::endgroup::"
|
||||
PREVIEW_URL=$(echo "$DEPLOY_OUTPUT" | grep -o 'Preview: https://[1]*' | awk '{print $2}')
|
||||
echo "PREVIEW_URL=$PREVIEW_URL" >> $GITHUB_ENV
|
||||
echo "::set-output name=preview_url::$PREVIEW_URL"
|
||||
if [[ -z "$PREVIEW_URL" ]]; then exit 1; fi
|
||||
continue-on-error: false
|
||||
- name: Comment on PR on Success
|
||||
if: ${{ success() && env.PREVIEW_URL }}
|
||||
uses: actions/github-script@v5
|
||||
with:
|
||||
script: |
|
||||
const previewUrl = '${{ env.PREVIEW_URL }}';
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.payload.pull_request.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Preview deployment successful.\n\nView Preview: ${previewUrl}`
|
||||
});
|
||||
- name: Comment on PR on Error
|
||||
if: ${{ failure() }}
|
||||
uses: actions/github-script@v5
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.payload.pull_request.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: 'Deployment encountered an issue. Please refer to the workflow logs for more information.'
|
||||
});
|
||||
```
|
||||
|
||||
## Production Deployments
|
||||
|
||||
Once the preview is approved, my `production.yaml` workflow deploys changes to my live site. It's very similar to the preview workflow, but tailored for the production environment:
|
||||
|
||||
```yaml
|
||||
name: Vercel Production Deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
Deploy-Production:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ">=18.14.1"
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
- name: Pull Vercel Environment Information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
```
|
||||
|
||||
## Open Source in Practice
|
||||
|
||||
Those workflows show how I use open source tools to maintain a flexible and efficient approach to development. Gitea's adaptability and the seamless integration with GitHub Actions demonstrates the power of open source in creating custom workflows without sacrificing efficiency.
|
||||
|
||||
## My Thoughts on This Approach
|
||||
|
||||
Using Gitea Actions has been a great experience for streamlining my deployment process. It highlights how open source development enables customization and the ability to tap into community resources. If you're seeking a flexible development environment that emphasizes open source principles, Gitea and Vercel make a powerful combination.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: "Introducing ClearProxy"
|
||||
published: 2025-05-22
|
||||
draft: false
|
||||
description: "ClearProxy is a modern web-based management interface for Caddy Server that simplifies reverse proxy configuration through an intuitive UI while maintaining Caddy's core features like automatic HTTPS and security defaults."
|
||||
tags: ['Caddy', 'ClearProxy']
|
||||
---
|
||||
|
||||
As a long-time user of [Caddy Server](https://caddyserver.com/), I've always appreciated its simplicity, automatic HTTPS capabilities, and robust performance. However, one pain point kept nagging at me: maintaining Caddyfile configurations across multiple servers and projects. That's what led me to create ClearProxy, a modern web-based management interface for Caddy that focuses on making reverse proxy configuration as straightforward as possible.
|
||||
|
||||
## The Journey
|
||||
|
||||
The idea for ClearProxy was born out of a simple desire: I wanted the power of Caddy without the hassle of manually editing configuration files. While Caddy's Caddyfile syntax is clean and intuitive, managing multiple proxy configurations across different environments can become tedious. I wanted a solution that would:
|
||||
|
||||
1. Provide a beautiful, intuitive interface for managing proxy hosts
|
||||
2. Maintain Caddy's core philosophy of simplicity and security
|
||||
3. Offer advanced features for power users without overwhelming beginners
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
I built ClearProxy using modern web technologies that prioritize performance and developer experience:
|
||||
|
||||
- **SvelteKit** for the frontend, offering a responsive and snappy user interface
|
||||
- **SQLite** for reliable data storage without the complexity of a separate database server
|
||||
- **Docker** for easy deployment and consistent environments
|
||||
- **Caddy's Admin API** for seamless integration with the Caddy server
|
||||
|
||||
The architecture is deliberately simple: two containers working in harmony - one running the ClearProxy application and another running Caddy server. This setup ensures that users get all the benefits of Caddy (automatic HTTPS, modern security defaults) while enjoying a user-friendly management interface.
|
||||
|
||||
## Key Features
|
||||
|
||||
Some of the features I'm most proud of include:
|
||||
|
||||
- **Intuitive Proxy Management:** Add and configure proxy hosts with just a few clicks
|
||||
- **Automatic HTTPS:** Leveraging Caddy's built-in ACME client for SSL/TLS certificates
|
||||
- **Basic Authentication:** Easily secure proxied hosts when needed
|
||||
- **Advanced Configuration:** Raw Caddyfile syntax support for power users
|
||||
- **Access Logging:** Built-in monitoring capabilities
|
||||
|
||||
## The Rewards
|
||||
|
||||
Building ClearProxy has been incredibly rewarding for a couple reasons:
|
||||
|
||||
1. **Learning Experiences:** The project pushed me to dive deep into Caddy's internals, modern web development practices, and container orchestration. Every challenge was an opportunity to learn something new.
|
||||
2. **Open Source Collaboration:** The project is open source, and I'm hoping and looking forward to collaborating with other developers to make it better.
|
||||
|
||||
## Looking Forward
|
||||
|
||||
ClearProxy is more than just a personal tool - it's becoming a project that makes Caddy more accesible to everyone. Future plans include:
|
||||
|
||||
- Enhanced monitoring and analytics
|
||||
- Support for more advanced Caddy features
|
||||
- Improved documentation and tutorials
|
||||
- Community-requested features and improvements
|
||||
|
||||
## Try It Yourself
|
||||
|
||||
If you're using Caddy and want to simplify your proxy management, give ClearProxy a try. The project is [available on GitHub](https://github.com/FoggyMtnDrifter/ClearProxy), and getting started is as simple as running a few Docker commands.
|
||||
|
||||
```bash
|
||||
mkdir clearproxy && cd clearproxy
|
||||
curl -L https://raw.githubusercontent.com/foggymtndrifter/clearproxy/main/docker-compose.yml -o docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Building ClearProxy has been a journey of solving a personal pain point that turned into something much bigger. It's a testament to the power of open source and the satisfaction that comes from creating tools that make developers' lives easier. If you're using Caddy or looking for a more modern reverse proxy solution, I encourage you to give ClearProxy a try and join our community.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Rocky Linux: A User's Guide to Contributing"
|
||||
published: 2024-04-14
|
||||
draft: false
|
||||
description: "Learn how to go from open source enthusiast to active contributor within the welcoming Rocky Linux community."
|
||||
tags: ['Rocky Linux', 'Open Source']
|
||||
---
|
||||
|
||||
I've always been passionate about open source software. The idea of collaborative communities building powerful tools freely available to everyone resonated deeply with me. Yet, for a long time, I was just a user, a beneficiary. I'd never taken the plunge into actually *contributing* to an open source project.
|
||||
|
||||
That all changed when the CentOS landscape shifted. Like many, I was caught off guard by the news around CentOS 8. Reading Greg Kurtzer's announcement of Rocky Linux on the CentOS blog, something clicked. This was my chance to put my belief in open source into action.
|
||||
|
||||
With a mix of excitement and apprehension, I joined the Rocky Linux Slack (which later transitioned to Mattermost). I had zero open source contribution experience, yet I found a welcoming community eager to bring enthusiastic people on board. I started by talking to the then Web Team Lead, and soon I was making small contributions to the Rocky Linux website.
|
||||
|
||||
My involvement grew quickly. As my contributions increased, so did my sense of ownership in the project. Before I knew it, I was leading the Web Team, overseeing the development and maintenance of the main Rocky Linux site. It was incredibly rewarding!
|
||||
|
||||
I've since scaled back my involvement in that specific role, but remain integral in shaping the project's visual identity as the Design Team Lead. The thrill of helping a crucial open source project remains the same.
|
||||
|
||||
## How You Can Make a Difference
|
||||
|
||||
My story isn't about being a technical genius. It's about the power of the Rocky Linux community, a community that throws its doors wide open and says, "Come build with us!" If you're intrigued by this project, here's your roadmap for getting involved:
|
||||
|
||||
1. **Join the Mattermost Chat:** The heart of the Rocky Linux community beats on [Mattermost](https://chat.rockylinux.org). Dive right in!
|
||||
2. **Explore Your Interests:** Find channels that align with your skills or areas you want to learn about. Maybe you like to write; documentation is always needed. Love to design? Join me on the Design Team. If you're a developer, well, the possibilities are endless.
|
||||
3. **Let Your Voice Be Heard:** Don't be shy! Introduce yourself within the channels, let people know you're interested in helping out. You'll be amazed at how quickly you'll be guided towards ways to contribute.
|
||||
|
||||
## Open Doors, Open Community
|
||||
|
||||
The core of Rocky Linux is its inclusivity. Whether you're a seasoned expert, a student wanting to learn, or someone who just wants to give back to the open source world, there's a place for you.
|
||||
|
||||
Don't think you have to become a team lead like I did to make a difference. Every pull request reviewed, every documentation page written, every design asset created; it all matters. We're not just building an operating system; we're building a vibrant community.
|
||||
|
||||
What are you waiting for? Join us! You might be surprised by how much you can achieve and who you might become.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Installing Virtual Machine Manager on Void Linux"
|
||||
published: 2024-04-22
|
||||
draft: false
|
||||
description: "This step-by-step tutorial shows you how to install and use Virtual Machine Manager on Void Linux."
|
||||
tags: ['Virtual Machine Manager', 'Void Linux']
|
||||
---
|
||||
|
||||
If you're a fellow Void Linux enthusiast like me, you know the thrill of a lean, customizable system. But sometimes, you might want to run other operating systems within your streamlined Void environment. That's where virtual machines (VMs) come to the rescue, and Virtual Machine Manager makes it super easy to set them up.
|
||||
|
||||
In this guide, I'll walk you through the steps I took to get this working.
|
||||
|
||||
## Step 1: Installing the Essentials
|
||||
|
||||
```bash
|
||||
sudo xbps-install libvirt virt-manager qemu polkit
|
||||
```
|
||||
|
||||
This gets us all the pieces we need; `libvirt` for virtualization magic, `virt-manager` for a friendly interface, `qemu` as our trusty emulator, and `polkit` for handling permissions.
|
||||
|
||||
## Step 2: Getting the Right Permissions
|
||||
|
||||
We need to make sure our regular user account can play with virtual machines. Let's add ourselves to the libvirt and kvm groups:
|
||||
|
||||
```bash
|
||||
sudo usermod -a -G libvirt,kvm your_username
|
||||
```
|
||||
|
||||
(Remember to replace `your_username` with your actual username.)
|
||||
|
||||
## Step 3: A Quick Log Out and Back In
|
||||
|
||||
Just to be sure the group changes stick, log out of your account and log back in.
|
||||
|
||||
## Step 4: A Tiny Bit of Configuration
|
||||
|
||||
Let's setup a config file for `libvirt` so it knows what's up:
|
||||
|
||||
```bash
|
||||
mkdir ~/.config/libvirt && sudo cp -rv /etc/libvirt/libvirt.conf ~/.config/libvirt/ && sudo chown your_username:your_user_group ~/.config/libvirt/libvirt.conf
|
||||
```
|
||||
|
||||
## Step 5: Tweaking libvirt Settings
|
||||
|
||||
Open `~/.config/libvirt/libvirt.conf` in your favorite text editor and find the line that says `uri_default`. Change it to:
|
||||
|
||||
```bash
|
||||
uri_default = "qemu:///system"
|
||||
```
|
||||
|
||||
## Step 6: QEMU Permissions
|
||||
|
||||
Edit `/etc/libvirt/qemu.conf`, setting the user and group to match your username and libvirt respectively. This lets you manage the VMs you create.
|
||||
|
||||
## Step 7: Starting the Services
|
||||
|
||||
Void Linux uses `runit` for services. Let's enable the ones we need:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/sv/dbus /var/service/
|
||||
sudo ln -s /etc/sv/polkitd /var/service/
|
||||
sudo ln -s /etc/sv/libvirtd /var/service/
|
||||
sudo ln -s /etc/sv/virtlockd /var/service/
|
||||
sudo ln -s /etc/sv/virtlogd /var/service/
|
||||
```
|
||||
|
||||
## Step 8: Launch Time!
|
||||
|
||||
That's it! Go ahead, launch Virtual Machine Manager, and get ready to spin up new virtual machines!
|
||||
|
||||
---
|
||||
|
||||
## Bonus Tip: Pump Up the Graphics
|
||||
|
||||
Want smoother graphics in your VMs? Edit a VM's settings, go to "Video", select "Virtio", and check the "3D Acceleration" box.
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module '@pagefind/default-ui' {
|
||||
declare class PagefindUI {
|
||||
constructor(arg: unknown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6.335 5.144C4.681 3.945 2 3.017 2 5.97c0 .59.35 4.953.556 5.661C3.269 14.094 5.686 14.381 8 14c-4.045.665-4.889 3.208-2.667 5.41C6.363 20.428 7.246 21 8 21c2 0 3.134-2.769 3.5-3.5q.5-1 .5-1.5q0 .5.5 1.5c.366.731 1.5 3.5 3.5 3.5c.754 0 1.637-.571 2.667-1.59C20.889 17.207 20.045 14.664 16 14c2.314.38 4.73.094 5.444-2.369c.206-.708.556-5.072.556-5.661c0-2.953-2.68-2.025-4.335-.826C15.372 6.806 12.905 10.192 12 12c-.905-1.808-3.372-5.194-5.665-6.856"/></svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up-icon lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>
|
||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-right-icon lucide-chevrons-right"><path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||
|
After Width: | Height: | Size: 312 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>
|
||||
|
After Width: | Height: | Size: 528 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-linkedin-icon lucide-linkedin"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/></svg>
|
||||
|
After Width: | Height: | Size: 399 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15.5 21.5c-10.5 2.5 -12.5 -2.5 -12.5 -8.5v-3c0 -6 2.5 -7 7 -7h4c4.5 0 7 1.5 7 5.5v4c0 6.5 -10 4 -13.5 4c-1 0 -1.5 7 8 5Z"/><path d="M7 13.5l0 -5.5c0 0 0.5 -2 2.5 -2c2 0 2.5 2 2.5 2l0 2.5l0 -2.5c0 0 0.5 -2 2.5 -2c2 0 2.5 2 2.5 2l0 5.5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon-icon lucide-moon"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-palette-icon lucide-palette"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 580 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rss-icon lucide-rss"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-moon-icon lucide-sun-moon"><path d="M12 2v2"/><path d="M13 8.129A4 4 0 0 1 15.873 11"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/><path d="M9 8a5 5 0 1 0 7 7 7 7 0 1 1-7-7"/></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-icon lucide-sun"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
|
||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-twitter-icon lucide-twitter"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,179 @@
|
||||
---
|
||||
import '~/styles/global.css'
|
||||
import '@fontsource/varela'
|
||||
import Header from '~/components/Header.astro'
|
||||
import Footer from '~/components/Footer.astro'
|
||||
import LightDarkAutoThemeLoader from '~/components/LightDarkAutoThemeLoader.astro'
|
||||
import SelectThemeLoader from '~/components/SelectThemeLoader.astro'
|
||||
import siteConfig from '~/site.config'
|
||||
import { pick, resolveThemeColorStyles } from '~/utils'
|
||||
import crypto from 'crypto'
|
||||
import BuyMeACoffeeModal from '~/components/BuyMeACoffeeModal.svelte'
|
||||
import CookieConsent from '~/components/CookieConsent.astro'
|
||||
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
author?: string
|
||||
}
|
||||
|
||||
const { title, description, tags, author } = Astro.props
|
||||
const pageUrl = new URL(Astro.url.pathname, Astro.site).href.replace(/\/$/, '') // Remove trailing slash for consistency
|
||||
const pageType = Astro.url.pathname.startsWith('/posts') ? 'article' : 'website'
|
||||
const pageTitle = title ? `${title} - ${siteConfig.title}` : siteConfig.title
|
||||
const pageDescription = description || siteConfig.description
|
||||
const pageAuthor = author || siteConfig.author
|
||||
const pageImage =
|
||||
pageType === 'article'
|
||||
? Astro.url.origin +
|
||||
Astro.url.pathname.replace(/\/posts\//, '/social-cards/') +
|
||||
'.png'
|
||||
: `${Astro.url.origin}/social-cards/__default.png`
|
||||
const pageKeywords = [
|
||||
...new Set(siteConfig.tags.concat(tags || []).map((word) => word.toLowerCase())),
|
||||
].join(', ')
|
||||
const baseCssVars: { [key: string]: string } = {
|
||||
'theme-font': siteConfig.font,
|
||||
'ec-frm-frameBoxShdCssVal': 'none',
|
||||
'ec-frm-edTabBrdRad': '0',
|
||||
'ec-frm-edTabBarBrdCol':
|
||||
'color-mix(in srgb, var(--theme-foreground), 10%, transparent)',
|
||||
'ec-brdCol': 'color-mix(in srgb, var(--theme-foreground), 10%, transparent)',
|
||||
}
|
||||
|
||||
let themeMode = siteConfig.themes.mode
|
||||
if (siteConfig.themes.include.length < 1) {
|
||||
throw new Error('No themes defined in site.config. Please add at least one theme.')
|
||||
}
|
||||
if (themeMode === 'light-dark-auto' && siteConfig.themes.include.length < 2) {
|
||||
console.warn(
|
||||
'Theme mode "dark-light-auto" requires at least two themes. Defaulting to "single".',
|
||||
)
|
||||
themeMode = 'single'
|
||||
}
|
||||
let defaultTheme = siteConfig.themes.default || siteConfig.themes.include[0]
|
||||
let includedThemes = siteConfig.themes.include as string[]
|
||||
const themeNotIncluded = !includedThemes.includes(defaultTheme)
|
||||
if (
|
||||
(themeMode !== 'light-dark-auto' && themeNotIncluded) ||
|
||||
(themeMode === 'light-dark-auto' && defaultTheme !== 'auto' && themeNotIncluded)
|
||||
) {
|
||||
console.warn(
|
||||
`Default theme "${defaultTheme}" not found in themes. Using first theme: "${siteConfig.themes.include[0]}".`,
|
||||
)
|
||||
defaultTheme = siteConfig.themes.include[0]
|
||||
}
|
||||
let lightTheme = themeMode === 'light-dark-auto' ? includedThemes[0] : undefined
|
||||
let darkTheme = themeMode === 'light-dark-auto' ? includedThemes[1] : undefined
|
||||
|
||||
// Generate a hash to use for cache busting the theme settings in localStorage.
|
||||
const themeHash = crypto
|
||||
.createHash('md5')
|
||||
.update(themeMode + defaultTheme + includedThemes.join(''))
|
||||
.digest('hex')
|
||||
.slice(0, 8) // Truncate to the first 8 characters
|
||||
|
||||
const resolvedThemes = await resolveThemeColorStyles(
|
||||
siteConfig.themes.include,
|
||||
siteConfig.themes.overrides,
|
||||
)
|
||||
|
||||
let cssLines: string[] = []
|
||||
for (const [themeId, themeStyles] of Object.entries(resolvedThemes)) {
|
||||
const relevantStyles = pick(themeStyles, [
|
||||
'foreground',
|
||||
'background',
|
||||
'accent',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'heading5',
|
||||
'heading6',
|
||||
'list',
|
||||
'italic',
|
||||
'link',
|
||||
'separator',
|
||||
'note',
|
||||
'tip',
|
||||
'important',
|
||||
'caution',
|
||||
'warning',
|
||||
'blue',
|
||||
'green',
|
||||
'red',
|
||||
'yellow',
|
||||
'magenta',
|
||||
'cyan',
|
||||
])
|
||||
cssLines.push(`:root[data-theme="${themeId}"] {`)
|
||||
for (const [key, value] of Object.entries(relevantStyles)) {
|
||||
cssLines.push(`--theme-${key}: ${value};`)
|
||||
}
|
||||
cssLines.push(`}`)
|
||||
}
|
||||
let generatedCss: string = cssLines.join('\n')
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html
|
||||
lang="en"
|
||||
data-theme={defaultTheme}
|
||||
data-dark-theme={darkTheme}
|
||||
data-light-theme={lightTheme}
|
||||
data-theme-mode={themeMode}
|
||||
data-theme-hash={themeHash}
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="title" content={pageTitle} />
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="author" content={pageAuthor} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:url" content={pageUrl} />
|
||||
<meta property="og:type" content={pageType} />
|
||||
{pageImage && <meta property="og:image" content={pageImage} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
{pageImage && <meta name="twitter:image" content={pageImage} />}
|
||||
<meta name="keywords" content={pageKeywords} />
|
||||
<link rel="canonical" href={pageUrl} />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={siteConfig.title}
|
||||
href={new URL('rss.xml', Astro.site)}
|
||||
/>
|
||||
<title>{pageTitle}</title>
|
||||
<style is:global define:vars={baseCssVars}></style>
|
||||
<style is:inline set:html={generatedCss}></style>
|
||||
{themeMode === 'light-dark-auto' && <LightDarkAutoThemeLoader />}
|
||||
{themeMode === 'select' && <SelectThemeLoader />}
|
||||
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.foggymtndrifter.com/script.js"
|
||||
data-website-id="c2e776cd-5a10-41d0-a5ef-cefc8f3d8499"></script>
|
||||
</head>
|
||||
<body class="w-full h-full m-0 bg-background text-foreground">
|
||||
<div
|
||||
class="flex flex-col max-w-3xl min-h-screen border-accent/10 m-auto p-3 sm:py-5 sm:px-6 md:py-10"
|
||||
>
|
||||
<Header />
|
||||
<main class="flex flex-col py-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<BuyMeACoffeeModal client:only="svelte" />
|
||||
<CookieConsent />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import type { MarkdownLayoutProps } from 'astro'
|
||||
|
||||
type Props = MarkdownLayoutProps<{
|
||||
// Define frontmatter props here
|
||||
title: string
|
||||
description?: string
|
||||
}>
|
||||
|
||||
// Now, `frontmatter`, `url`, and other Markdown layout properties
|
||||
// are accessible with type safety
|
||||
const { frontmatter } = Astro.props
|
||||
---
|
||||
|
||||
<Layout title={frontmatter.title} description={frontmatter.description}>
|
||||
<div class="max-w-full py-7.5">
|
||||
<h1 class="md:mx-2 mb-3 text-[1.75rem] text-heading1 font-semibold">
|
||||
# {frontmatter.title}
|
||||
</h1>
|
||||
<div class="mb-5 prose">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<h1 class="inline-block m-auto p-4 text-accent text-4xl">404</h1>
|
||||
</Layout>
|
||||
@@ -0,0 +1,368 @@
|
||||
---
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import { Image } from 'astro:assets'
|
||||
import myAvatar from '../content/avatar.jpeg'
|
||||
import siteConfig from '~/site.config'
|
||||
|
||||
import IconEmail from '~/icons/email.svg'
|
||||
import BlockHeader from '~/components/BlockHeader.astro'
|
||||
|
||||
const title = 'About Michael Kinder'
|
||||
const description = 'Professional experience and skills of Michael Kinder.'
|
||||
---
|
||||
|
||||
<Layout title={title} description={description}>
|
||||
<div class="max-w-3xl mx-auto py-8">
|
||||
<!-- Header Section -->
|
||||
<section
|
||||
class="mb-12 flex flex-col sm:flex-row items-center sm:items-center gap-12 border-b-2 border-accent/20 pb-8"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 relative group w-32 h-32 flex items-center justify-center sm:ml-6"
|
||||
>
|
||||
<Image
|
||||
src={myAvatar}
|
||||
alt="Michael Kinder"
|
||||
class="rounded-full w-32 h-32 border-4 border-accent object-cover shadow-lg relative z-10"
|
||||
/>
|
||||
{
|
||||
siteConfig.hireMe && (
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 pointer-events-none opacity-90 group-hover:opacity-100 transition-opacity">
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
class="w-full h-full text-accent animate-spin-slow"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="circlePath"
|
||||
d="M 100, 100 m -75, 0 a 75,75 0 1,1 150,0 a 75,75 0 1,1 -150,0"
|
||||
/>
|
||||
</defs>
|
||||
<text
|
||||
fill="currentColor"
|
||||
font-size="13.5"
|
||||
font-weight="bold"
|
||||
letter-spacing="2"
|
||||
class="uppercase"
|
||||
>
|
||||
<textPath
|
||||
href="#circlePath"
|
||||
startOffset="0%"
|
||||
textLength="471"
|
||||
lengthAdjust="spacing"
|
||||
>
|
||||
Available for Hire • Available for Hire •
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="text-center sm:text-left">
|
||||
<h1 class="text-4xl font-bold text-heading1 mb-2">Michael Kinder</h1>
|
||||
<p class="text-xl text-heading2 italic mb-4">Technical Leader</p>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap justify-center sm:justify-start gap-4 text-sm text-foreground/80"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-map-pin"
|
||||
><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"></path><circle
|
||||
cx="12"
|
||||
cy="10"
|
||||
r="3"></circle></svg
|
||||
>
|
||||
Pikeville, Kentucky
|
||||
</span>
|
||||
{
|
||||
siteConfig.socialLinks.email && (
|
||||
<span class="flex items-center gap-1">
|
||||
<IconEmail class="size-4" />
|
||||
<a href={`mailto:${siteConfig.socialLinks.email}`} class="disabled-link">
|
||||
{siteConfig.socialLinks.email.replace('mailto:', '')}
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<section class="mb-12">
|
||||
<BlockHeader>Summary</BlockHeader>
|
||||
<p class="leading-relaxed text-foreground/90 my-5">
|
||||
Located in Pikeville, KY, I am a Technical Support professional with hands-on
|
||||
experience providing remote end-user support, troubleshooting hardware and
|
||||
software issues, and leading technical support teams in fast-paced hosting and
|
||||
services environments.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Experience Section -->
|
||||
<section class="mb-12">
|
||||
<BlockHeader>Experience</BlockHeader>
|
||||
|
||||
<div class="relative border-l-2 border-accent/20 ml-3 pl-8 pb-8 space-y-8 my-5">
|
||||
{/* Experience Item 1 */}
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-accent"
|
||||
></span>
|
||||
<h3 class="text-xl font-bold text-heading3">Technical Support Team Lead</h3>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-accent font-semibold">hosting.com</span>
|
||||
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
|
||||
>Mar 2023 - Dec 2025</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
|
||||
>
|
||||
<li>
|
||||
Led a team of 32 employees, providing guidance and support to ensure
|
||||
successful completion of projects.
|
||||
</li>
|
||||
<li>
|
||||
Implemented effective communication strategies to foster collaboration and
|
||||
improve team productivity.
|
||||
</li>
|
||||
<li>
|
||||
Developed and implemented training programs for new team members, resulting
|
||||
in reduced onboarding time.
|
||||
</li>
|
||||
<li>
|
||||
Monitored team performance and provided regular feedback to drive continous
|
||||
improvement.
|
||||
</li>
|
||||
<li>
|
||||
Collaborated with cross-functional teams to identify process improvements
|
||||
and implement best practices.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Experience Item 2 (Inferred/Generic based on "career") */}
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
|
||||
>
|
||||
<span class="block h-full w-full rounded-full bg-accent/50"></span>
|
||||
</span>
|
||||
<h3 class="text-xl font-bold text-heading3">Technical Support Specialist</h3>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-accent font-semibold">InMotion Hosting</span>
|
||||
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
|
||||
>Feb 2022 - Feb 2023</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
|
||||
>
|
||||
<li>
|
||||
Provided technical support to end-users and troubleshooting hardware and
|
||||
software issues.
|
||||
</li>
|
||||
<li>
|
||||
Resolved 95% of customer inquiries on the first call and exceeding
|
||||
departmental targets.
|
||||
</li>
|
||||
<li>
|
||||
Collaborated with cross-functional teams to identify and resolve complex
|
||||
technical problems.
|
||||
</li>
|
||||
<li>
|
||||
Documented all support interactions accurately and thoroughly in the
|
||||
ticketing system.
|
||||
</li>
|
||||
<li>
|
||||
Performed remote troubleshooting through diagnostic techniques and pertinent
|
||||
questions.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
|
||||
>
|
||||
<span class="block h-full w-full rounded-full bg-accent/50"></span>
|
||||
</span>
|
||||
<h3 class="text-xl font-bold text-heading3">Data Services Expert</h3>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-accent font-semibold">Intuit</span>
|
||||
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
|
||||
>Sep 2018 - May 2021</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
|
||||
>
|
||||
<li>
|
||||
Repaired and restored QuickBooks databases, ensuring data integrity, and
|
||||
minimizing downtime for small business users.
|
||||
</li>
|
||||
<li>
|
||||
Utilized SQL for data analysis, troubleshooting, and custom Query
|
||||
development to resolve complex data-related issues.
|
||||
</li>
|
||||
<li>
|
||||
Leveraged Splunk to investigate service anomalies, identify root causes, and
|
||||
support resolution workflows.
|
||||
</li>
|
||||
<li>
|
||||
Interpreted large datasets to diagnose problems, implement fixes, and
|
||||
deliver accurate and timely support solutions.
|
||||
</li>
|
||||
<li>
|
||||
Maintained a strong focus on data privacy and customer satisfaction in a
|
||||
high-volume and customercentric environment.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
|
||||
>
|
||||
<span class="block h-full w-full rounded-full bg-accent/50"></span>
|
||||
</span>
|
||||
<h3 class="text-xl font-bold text-heading3">Tier 2 AppleCare Advisor</h3>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-accent font-semibold">Apple</span>
|
||||
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
|
||||
>Apr 2018 - Sep 2018</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
|
||||
>
|
||||
<li>
|
||||
Provided technical support to customers via phone, email, chat, and resolved
|
||||
95% of issues on the first contact.
|
||||
</li>
|
||||
<li>
|
||||
Collaborated with Tier 1 support team to escalate complex issues and ensure
|
||||
timely resolution.
|
||||
</li>
|
||||
<li>
|
||||
Troubleshot hardware and software problems for a wide range of products
|
||||
including computers, printers, routers, and mobile devices.
|
||||
</li>
|
||||
<li>
|
||||
Demonstrated expertise in troubleshooting network connectivity issues by
|
||||
analyzing logs and conducting remote diagnostics.
|
||||
</li>
|
||||
<li>
|
||||
Assisted customers in configuring software applications and resolving
|
||||
compatibility issues.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
|
||||
>
|
||||
<span class="block h-full w-full rounded-full bg-accent/50"></span>
|
||||
</span>
|
||||
<h3 class="text-xl font-bold text-heading3">Assistant Store Manager</h3>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-accent font-semibold">Family Dollar</span>
|
||||
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
|
||||
>May 2017 - May 2018</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
|
||||
>
|
||||
<li>
|
||||
Assisted store manager in overseeing daily operations including inventory
|
||||
management, staff scheduling, and customer service.
|
||||
</li>
|
||||
<li>
|
||||
Trained and mentored new employees on company policies, procedures, and
|
||||
customer service standards.
|
||||
</li>
|
||||
<li>
|
||||
Collaborated with the store manager to develop and implement effective
|
||||
merchandising displays to drive sales.
|
||||
</li>
|
||||
<li>
|
||||
Managed cash handling procedures and ensured accuracy and compliance with
|
||||
company guidelines.
|
||||
</li>
|
||||
<li>
|
||||
Resolved customer complaints or concerns promptly and effectively, to
|
||||
maintain high levels of customer satisfaction.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Skills Section -->
|
||||
<section class="mb-12">
|
||||
<BlockHeader>Skills</BlockHeader>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 my-5">
|
||||
<div class="bg-accent/7 p-4 rounded-xl">
|
||||
<h3 class="font-bold text-heading4 mb-3 border-b border-accent/10 pb-2">
|
||||
Technical
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
{
|
||||
[
|
||||
'Computer Science',
|
||||
'Problem-Solving',
|
||||
'e-Commerce',
|
||||
'Linux',
|
||||
'Computer Operation',
|
||||
'Analysis Skills',
|
||||
'Typing',
|
||||
].map((skill) => (
|
||||
<span class="py-1 px-3 bg-accent/1 hover:bg-accent/8 border border-accent/20 text-accent/90 rounded-2xl transition-colors cursor-default">
|
||||
{skill}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-accent/7 p-4 rounded-xl">
|
||||
<h3 class="font-bold text-heading4 mb-3 border-b border-accent/10 pb-2">
|
||||
General
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
{
|
||||
[
|
||||
'Customer Service',
|
||||
'Leadership',
|
||||
'Team Training',
|
||||
'Teamwork',
|
||||
'Time Management',
|
||||
'Communication Skills',
|
||||
].map((skill) => (
|
||||
<span class="py-1 px-3 bg-accent/1 hover:bg-accent/8 border border-accent/20 text-accent/90 rounded-2xl transition-colors cursor-default">
|
||||
{skill}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
export const GET: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const storedState = cookies.get('github_oauth_state')?.value
|
||||
|
||||
if (!state || !storedState || state !== storedState) {
|
||||
return new Response('Invalid state', { status: 400 })
|
||||
}
|
||||
|
||||
cookies.delete('github_oauth_state', { path: '/' })
|
||||
|
||||
const client_id = import.meta.env.GITHUB_CLIENT_ID
|
||||
const client_secret = import.meta.env.GITHUB_CLIENT_SECRET
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
return new Response('Missing GitHub credentials', { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id,
|
||||
client_secret,
|
||||
code,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
return new Response(data.error_description || 'OAuth Error', { status: 400 })
|
||||
}
|
||||
|
||||
const { access_token } = data
|
||||
|
||||
// Securely store the token
|
||||
cookies.set('github_access_token', access_token, {
|
||||
path: '/',
|
||||
secure: import.meta.env.PROD,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
sameSite: 'lax',
|
||||
})
|
||||
|
||||
const redirectTo = cookies.get('github_redirect_to')?.value
|
||||
cookies.delete('github_redirect_to', { path: '/' })
|
||||
|
||||
return redirect(redirectTo || '/')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return new Response('Authentication failed', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
export const GET: APIRoute = async ({ request, redirect, cookies }) => {
|
||||
const client_id = import.meta.env.GITHUB_CLIENT_ID
|
||||
if (!client_id) {
|
||||
return new Response('Missing GITHUB_CLIENT_ID', { status: 500 })
|
||||
}
|
||||
|
||||
const state = crypto.randomUUID()
|
||||
cookies.set('github_oauth_state', state, {
|
||||
path: '/',
|
||||
secure: import.meta.env.PROD,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
sameSite: 'lax',
|
||||
})
|
||||
|
||||
const url = new URL(request.url)
|
||||
const redirectTo = url.searchParams.get('redirect_to')
|
||||
if (redirectTo) {
|
||||
cookies.set('github_redirect_to', redirectTo, {
|
||||
path: '/',
|
||||
secure: import.meta.env.PROD,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
sameSite: 'lax',
|
||||
})
|
||||
}
|
||||
|
||||
// Store the return URL if provided
|
||||
// We can't easily pass it through state efficiently without encoding,
|
||||
// so let's check headers or a query param if we want to support deep linking back.
|
||||
// For now, let's keep it simple. callback will handle redirect.
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id,
|
||||
scope: 'public_repo read:user user:email',
|
||||
state,
|
||||
})
|
||||
|
||||
return redirect(`https://github.com/login/oauth/authorize?${params.toString()}`)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
export const GET: APIRoute = async ({ cookies, redirect }) => {
|
||||
cookies.delete('github_access_token', { path: '/' })
|
||||
return redirect('/')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { Octokit } from 'octokit'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
export const GET: APIRoute = async ({ cookies }) => {
|
||||
const token = cookies.get('github_access_token')?.value
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ user: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = new Octokit({ auth: token })
|
||||
const { data: user } = await octokit.rest.users.getAuthenticated()
|
||||
|
||||
return new Response(JSON.stringify({ user }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error)
|
||||
// If token is invalid, clear it?
|
||||
// cookies.delete('github_access_token', { path: '/' })
|
||||
return new Response(JSON.stringify({ user: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
export const prerender = false
|
||||
import Stripe from 'stripe'
|
||||
|
||||
// Use a dummy key if env var is missing to allow build to pass (e.g. during static generation or if user hasn't set it yet)
|
||||
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY || 'sk_test_dummy'
|
||||
if (STRIPE_SECRET_KEY === 'sk_test_dummy') {
|
||||
console.warn('Using dummy Stripe key. Checkout will fail.')
|
||||
}
|
||||
const stripe = new Stripe(STRIPE_SECRET_KEY)
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect }) => {
|
||||
let amount: number
|
||||
let isCustom: boolean
|
||||
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = await request.json()
|
||||
amount = parseFloat(json.amount || '0')
|
||||
isCustom = json.isCustom === true
|
||||
} else if (contentType.includes('multipart/form-data') || contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const data = await request.formData()
|
||||
amount = parseFloat(data.get('amount')?.toString() || '0')
|
||||
isCustom = data.get('isCustom') === 'true'
|
||||
} else {
|
||||
console.error('Unexpected Content-Type:', contentType)
|
||||
return new Response(JSON.stringify({ error: 'Invalid Content-Type' }), { status: 400 })
|
||||
}
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid amount' }), { status: 400 })
|
||||
}
|
||||
|
||||
// Ensure minimum amount for Stripe (usually $0.50 USD)
|
||||
if (amount < 0.5) {
|
||||
return new Response(JSON.stringify({ error: 'Amount must be at least $0.50' }), { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: 'usd',
|
||||
product_data: {
|
||||
name: 'Buy me a coffee',
|
||||
description: isCustom ? 'Custom donation' : `Support for FoggyMtnDrifter`,
|
||||
},
|
||||
unit_amount: Math.round(amount * 100), // Stripe expects cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
success_url: `${request.headers.get('origin')}/?success=true`,
|
||||
cancel_url: `${request.headers.get('origin')}/?canceled=true`,
|
||||
})
|
||||
|
||||
if (session.url) {
|
||||
return new Response(JSON.stringify({ url: session.url }), { status: 200 })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Failed to create session' }), { status: 500 })
|
||||
|
||||
} catch (err) {
|
||||
console.error('Stripe error:', err)
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { Octokit } from 'octokit'
|
||||
import siteConfig from '~/site.config'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
const GITHUB_TOKEN = import.meta.env.GITHUB_PERSONAL_ACCESS_TOKEN
|
||||
const OWNER = 'FoggyMtnDrifter'
|
||||
const REPO = 'website'
|
||||
|
||||
// Helper to get Octokit instance
|
||||
const getOctokit = (auth?: string) => new Octokit({ auth: auth || GITHUB_TOKEN })
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
if (!siteConfig.comments) {
|
||||
return new Response(JSON.stringify({ error: 'Comments not configured' }), { status: 500 })
|
||||
}
|
||||
|
||||
const { slug } = params
|
||||
// Mapping is pathname. With the slug, we construct the pathname.
|
||||
// Assuming slugs align with pathnames. E.g. posts/slug -> /posts/slug OR just The Title?
|
||||
// "pathname" mapping usually uses the pathname of the page.
|
||||
// The user's pages are at /posts/[slug]. So search term is likely `/posts/${slug}` or just `/${slug}`?
|
||||
// Let's assume `/posts/${slug}` based on typical structure.
|
||||
// Wait, the slug param might capture the whole path if it was [...slug], but it's [slug].
|
||||
// Let's verify the logic in `src/pages/posts/[slug].astro`.
|
||||
// The comments loader uses `pathname`.
|
||||
|
||||
const term = `/posts/${slug}`
|
||||
|
||||
const query = `
|
||||
query($term: String!) {
|
||||
search(type: DISCUSSION, query: $term, first: 1) {
|
||||
nodes {
|
||||
... on Discussion {
|
||||
id
|
||||
title
|
||||
number
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
replies(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
try {
|
||||
const octokit = getOctokit()
|
||||
const searchQuery = `repo:${siteConfig.comments.repo} in:title ${term}`
|
||||
|
||||
const result: any = await octokit.graphql(query, {
|
||||
term: searchQuery
|
||||
})
|
||||
|
||||
const discussion = result.search?.nodes?.[0]
|
||||
|
||||
if (discussion && discussion.comments) {
|
||||
const transformComment = (comment: any) => {
|
||||
const attributionMatch = comment.body.match(/\n\n_\(Posted by (.*?)\)_$/)
|
||||
if (attributionMatch) {
|
||||
const displayName = attributionMatch[1]
|
||||
comment.author.login = displayName
|
||||
comment.author.url = '' // Guest has no profile
|
||||
// Use DiceBear for guest avatar
|
||||
comment.author.avatarUrl = `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(displayName)}`
|
||||
}
|
||||
|
||||
// The HTML is: <p dir="auto"><em>(Posted by Name)</em></p>
|
||||
// parsing HTML with regex is fragile but for this specific pattern it's consistent from GitHub
|
||||
// We accept optional whitespace and attributes on the p tag.
|
||||
if (comment.bodyHTML) {
|
||||
comment.bodyHTML = comment.bodyHTML.replace(/<p[^>]*>\s*<em>\(Posted by .*?\)<\/em>\s*<\/p>\s*$/, '')
|
||||
}
|
||||
|
||||
if (comment.replies && comment.replies.nodes) {
|
||||
comment.replies.nodes.forEach(transformComment)
|
||||
// Sort replies: Newest first
|
||||
comment.replies.nodes.reverse()
|
||||
}
|
||||
}
|
||||
|
||||
discussion.comments.nodes.forEach(transformComment)
|
||||
// Sort comments: Newest first
|
||||
discussion.comments.nodes.reverse()
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ discussion }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error)
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch comments' }), { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ params, request, cookies }) => {
|
||||
const { slug } = params
|
||||
const term = `/posts/${slug}`
|
||||
|
||||
let body
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch (e) {
|
||||
return new Response('Invalid JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const { content, displayName, discussionId, website_honey, captcha } = body
|
||||
const userToken = cookies.get('github_access_token')?.value
|
||||
|
||||
// 1. Honeypot Check
|
||||
if (website_honey) {
|
||||
// Silently fail or return error. Let's return error to stop processing.
|
||||
console.log('Honeypot triggered')
|
||||
return new Response('Spam detected', { status: 400 })
|
||||
}
|
||||
|
||||
// 2. Basic Math Captcha Check (for guests or everyone? User said safeguards. Let's apply to anonymous mainly, but maybe all for safety? Let's apply to ANONYMOUS only as auth users are trusted)
|
||||
if (!userToken) {
|
||||
if (!captcha || !captcha.num1 || !captcha.num2 || !captcha.answer) {
|
||||
return new Response('Captcha required', { status: 400 })
|
||||
}
|
||||
const { num1, num2, answer } = captcha
|
||||
if (parseInt(num1) + parseInt(num2) !== parseInt(answer)) {
|
||||
return new Response('Incorrect math answer', { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which token and attribution to use
|
||||
let finalContent = content
|
||||
let auth = userToken
|
||||
|
||||
if (!auth) {
|
||||
// Anonymous mode
|
||||
if (!displayName) {
|
||||
return new Response('Display name required for anonymous comments', { status: 400 })
|
||||
}
|
||||
auth = GITHUB_TOKEN // Use bot token
|
||||
finalContent = `${content}\n\n_(Posted by ${displayName})_`
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return new Response('Server configuration error: No bot token available', { status: 500 })
|
||||
}
|
||||
|
||||
const octokit = getOctokit(auth)
|
||||
|
||||
try {
|
||||
let finalDiscussionId = discussionId
|
||||
|
||||
// If no discussion ID provided, we must find or create it.
|
||||
// This requires the BOT token usually, as regular users might not have permission to create discussions in the category?
|
||||
// Actually, users usually CAN create discussions if the repo is public.
|
||||
// But for consistency, let's look it up first.
|
||||
|
||||
if (!finalDiscussionId) {
|
||||
// Logic to find or create discussion...
|
||||
// Re-use logic from GET or extracting it to a shared helper would be better.
|
||||
// For now, let's just error if not found to keep it simple, or implement create logic.
|
||||
|
||||
// Let's implement Find-Or-Create logic using Bot Token (to ensure it's created correctly).
|
||||
const botOctokit = getOctokit(GITHUB_TOKEN)
|
||||
|
||||
// 1. Find
|
||||
const searchQuery = `repo:${siteConfig.comments!.repo} in:title ${term}`
|
||||
const findQuery = `
|
||||
query($term: String!) {
|
||||
search(type: DISCUSSION, query: $term, first: 1) {
|
||||
nodes { ... on Discussion { id } }
|
||||
}
|
||||
}
|
||||
`
|
||||
const findResult: any = await botOctokit.graphql(findQuery, { term: searchQuery })
|
||||
const found = findResult.search.nodes[0]
|
||||
|
||||
if (found) {
|
||||
finalDiscussionId = found.id
|
||||
} else {
|
||||
// 2. Create
|
||||
if (!siteConfig.comments?.repoId || !siteConfig.comments?.categoryId) {
|
||||
return new Response('Missing repoId or categoryId configuration', { status: 500 })
|
||||
}
|
||||
|
||||
const createQuery = `
|
||||
mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
|
||||
createDiscussion(input: {
|
||||
repositoryId: $repositoryId,
|
||||
categoryId: $categoryId,
|
||||
title: $title,
|
||||
body: $body
|
||||
}) {
|
||||
discussion { id }
|
||||
}
|
||||
}
|
||||
`
|
||||
// GitHub Discussions usually creates it with a specific body.
|
||||
const createResult: any = await botOctokit.graphql(createQuery, {
|
||||
repositoryId: siteConfig.comments.repoId,
|
||||
categoryId: siteConfig.comments.categoryId,
|
||||
title: term,
|
||||
body: `Comments for ${term}\n\n[View Post](${new URL(term, siteConfig.site)})`
|
||||
})
|
||||
|
||||
finalDiscussionId = createResult.createDiscussion.discussion.id
|
||||
}
|
||||
}
|
||||
|
||||
const createCommentQuery = `
|
||||
mutation($discussionId: ID!, $body: String!) {
|
||||
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
|
||||
comment {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const response: any = await octokit.graphql(createCommentQuery, {
|
||||
discussionId: finalDiscussionId,
|
||||
body: finalContent
|
||||
})
|
||||
|
||||
const newComment = response.addDiscussionComment.comment
|
||||
|
||||
// If it was an anonymous post, we want to return it with the display name immediately
|
||||
// so the UI updates correctly without a refetch.
|
||||
if (!userToken && displayName) {
|
||||
newComment.author.login = displayName
|
||||
newComment.author.url = ''
|
||||
newComment.author.avatarUrl = `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(displayName)}`
|
||||
// Strip the attribution from bodyHTML if present
|
||||
if (newComment.bodyHTML) {
|
||||
newComment.bodyHTML = newComment.bodyHTML.replace(/<p[^>]*>\s*<em>\(Posted by .*?\)<\/em>\s*<\/p>\s*$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(newComment), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error posting comment:', error)
|
||||
return new Response(JSON.stringify({ error: 'Failed to post comment', details: error }), { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
export const prerender = false
|
||||
import Stripe from 'stripe'
|
||||
|
||||
// Use a dummy key if env var is missing to allow build to pass
|
||||
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY || 'sk_test_dummy'
|
||||
const stripe = new Stripe(STRIPE_SECRET_KEY)
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const amount = parseFloat(body.amount)
|
||||
const isCustom = body.isCustom === true
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid amount' }), { status: 400 })
|
||||
}
|
||||
|
||||
// Ensure minimum amount for Stripe (custom requirement: $5 USD)
|
||||
if (amount < 5) {
|
||||
return new Response(JSON.stringify({ error: 'Amount must be at least $5.00' }), { status: 400 })
|
||||
}
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(amount * 100), // Stripe expects cents
|
||||
currency: 'usd',
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
description: isCustom ? 'Custom donation' : 'Support for FoggyMtnDrifter',
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify({ clientSecret: paymentIntent.client_secret }), { status: 200 })
|
||||
|
||||
} catch (err) {
|
||||
console.error('Stripe error:', err)
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import { getSortedPosts } from '~/utils'
|
||||
import { getCollection, render } from 'astro:content'
|
||||
import PostPreview from '~/components/PostPreview.astro'
|
||||
import Pagination from '~/components/Pagination.astro'
|
||||
import BlockHeader from '~/components/BlockHeader.astro'
|
||||
import HomeBanner from '~/components/HomeBanner.astro'
|
||||
import siteConfig from '~/site.config'
|
||||
import TagsSection from '~/components/TagsSection.astro'
|
||||
import SeriesSection from '~/components/SeriesSection.astro'
|
||||
|
||||
const home = await getCollection('home')
|
||||
let HomeContent
|
||||
let homeAvatarImage
|
||||
let homeGithubCalendar
|
||||
if (home.length > 0) {
|
||||
const homeEntry = home[0]
|
||||
const { Content } = await render(homeEntry)
|
||||
HomeContent = Content
|
||||
homeAvatarImage = homeEntry.data.avatarImage
|
||||
homeGithubCalendar = homeEntry.data.githubCalendar
|
||||
}
|
||||
const sortedPosts = await getSortedPosts()
|
||||
const postsHaveTags = sortedPosts.some(
|
||||
(post) => post.data.tags && post.data.tags.length > 0,
|
||||
)
|
||||
|
||||
const postsHaveSeries = sortedPosts.some((post) => post.data.series)
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{
|
||||
HomeContent && (
|
||||
<HomeBanner avatarImage={homeAvatarImage} githubCalendar={homeGithubCalendar}>
|
||||
<HomeContent />
|
||||
</HomeBanner>
|
||||
)
|
||||
}
|
||||
{
|
||||
postsHaveSeries && (
|
||||
<section>
|
||||
<BlockHeader>Series</BlockHeader>
|
||||
<SeriesSection posts={sortedPosts} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
{
|
||||
postsHaveTags && (
|
||||
<section>
|
||||
<BlockHeader>Tags</BlockHeader>
|
||||
<TagsSection posts={sortedPosts} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
{
|
||||
sortedPosts.length > 0 && (
|
||||
<section>
|
||||
<BlockHeader>Latest Posts</BlockHeader>
|
||||
{sortedPosts
|
||||
.reverse()
|
||||
.slice(0, Math.floor(siteConfig.pageSize / 2))
|
||||
.map((post) => (
|
||||
<PostPreview post={post} />
|
||||
))}
|
||||
<Pagination nextLink="/posts" nextText="All Posts" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</Layout>
|
||||
|
||||
<style is:global>
|
||||
a.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content'
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const legalEntries = await getCollection('legal')
|
||||
return legalEntries.map((entry) => ({
|
||||
params: { slug: entry.id },
|
||||
props: { entry },
|
||||
}))
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
const { entry } = Astro.props
|
||||
const { Content } = await render(entry)
|
||||
---
|
||||
|
||||
<Layout title={entry.data.title}>
|
||||
<article class="max-w-full py-7.5 prose dark:prose-invert mx-auto">
|
||||
<Content />
|
||||
<hr class="my-8 border-accent/10" />
|
||||
<p class="text-sm text-gray-500 italic">
|
||||
Last updated: {
|
||||
entry.data.updated.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</p>
|
||||
</article>
|
||||
</Layout>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import { getSortedPosts } from '~/utils'
|
||||
import Pagination from '~/components/Pagination.astro'
|
||||
import siteConfig from '~/site.config'
|
||||
import PostPreview from '~/components/PostPreview.astro'
|
||||
import PageHeader from '~/components/PageHeader.astro'
|
||||
|
||||
// Note: Pagination like '/', '/2', '/3' only works with spread param like [...page]
|
||||
export const getStaticPaths = (async ({ paginate }) => {
|
||||
const sortedPosts = await getSortedPosts()
|
||||
return paginate(sortedPosts.reverse(), { pageSize: siteConfig.pageSize })
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
const { page } = Astro.props
|
||||
const pageTitle = 'Archive' + (page.currentPage > 1 ? ` - Page ${page.currentPage}` : '')
|
||||
---
|
||||
|
||||
<Layout title={pageTitle} description="All posts in the archive">
|
||||
<div class="mt-2 sm:mt-0">
|
||||
<PageHeader />
|
||||
{page.data.map((post) => <PostPreview post={post} />)}
|
||||
<Pagination
|
||||
prevLink={page.url.prev ? page.url.prev : undefined}
|
||||
prevText="Newer Posts"
|
||||
nextLink={page.url.next ? page.url.next : undefined}
|
||||
nextText="Older Posts"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import { SeriesGroup, TagsGroup, getSortedPosts } from '~/utils'
|
||||
import PostPreview from '~/components/PostPreview.astro'
|
||||
import DividerText from '~/components/DividerText.astro'
|
||||
import { getCollection, render, type CollectionEntry } from 'astro:content'
|
||||
import PostAddendum from '~/components/PostAddendum.astro'
|
||||
import TableOfContents from '~/components/TableOfContents.astro'
|
||||
import { Image } from 'astro:assets'
|
||||
import Comments from '~/components/Comments.svelte'
|
||||
import siteConfig from '~/site.config'
|
||||
import Tags from '~/components/Tags.astro'
|
||||
import PostInfo from '~/components/PostInfo.astro'
|
||||
import ScrollUpButton from '~/components/ScrollUpButton.astro'
|
||||
import type { Collation } from '~/types'
|
||||
import ChevronsRight from '~/icons/chevrons-right.svg'
|
||||
import { getPostSequenceContext } from '~/utils'
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const posts = await getSortedPosts()
|
||||
return posts.map((post) => {
|
||||
// Get sequence context for the post
|
||||
const { prev, next } = getPostSequenceContext(post, posts)
|
||||
return {
|
||||
params: { slug: post.id },
|
||||
props: { post, prev, next },
|
||||
}
|
||||
})
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
const { post, prev, next } = Astro.props
|
||||
const postData = post.data
|
||||
const { headings, Content: PostContent } = await render(post)
|
||||
const addendum = await getCollection('addendum')
|
||||
let AddendumContent
|
||||
let addendumAvatarImage
|
||||
if (addendum.length > 0) {
|
||||
const addendumEntry = addendum[0]
|
||||
const { Content } = await render(addendumEntry)
|
||||
AddendumContent = Content
|
||||
addendumAvatarImage = addendumEntry.data.avatarImage
|
||||
}
|
||||
|
||||
const sortedPosts = await getSortedPosts()
|
||||
|
||||
// Get series posts if this post is part of a series
|
||||
let series: Collation<'posts'> | undefined
|
||||
let nextPostInSeries: CollectionEntry<'posts'> | undefined
|
||||
if (postData.series) {
|
||||
const seriesGroup = await SeriesGroup.build(sortedPosts)
|
||||
series = seriesGroup.match(postData.series)
|
||||
if (!series) {
|
||||
// This should not happen if series data is correct
|
||||
throw new Error(`Series "${postData.series}" not found`)
|
||||
}
|
||||
const sequenceContext = getPostSequenceContext(post, series.entries)
|
||||
nextPostInSeries = sequenceContext.next
|
||||
}
|
||||
const showSeries = series && series.entries.length > 1
|
||||
|
||||
let tags: Collation<'posts'>[] | undefined
|
||||
if (postData.tags && postData.tags.length > 0) {
|
||||
const tagsGroup = await TagsGroup.build(sortedPosts)
|
||||
tags = tagsGroup.matchMany(postData.tags)
|
||||
}
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={postData.title}
|
||||
description={postData.description}
|
||||
author={postData.author}
|
||||
tags={postData.tags}
|
||||
>
|
||||
<article class="max-w-full py-7.5" data-pagefind-body>
|
||||
{
|
||||
postData.coverImage && (
|
||||
<Image
|
||||
priority
|
||||
layout="constrained"
|
||||
src={postData.coverImage.src}
|
||||
alt={postData.coverImage.alt}
|
||||
class="w-full rounded-xl mb-5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div class="md:mx-2">
|
||||
<h1 id={post.id} class="mb-4 text-[1.75rem] text-heading1 font-semibold">
|
||||
{postData.title}
|
||||
</h1>
|
||||
<div class="my-2 border-l-2 border-accent/80 pl-4 py-2">
|
||||
<PostInfo post={post} class="mb-1" />
|
||||
{
|
||||
tags && (
|
||||
<div class="mt-4">
|
||||
<Tags tags={tags} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <hr class="border-accent/10 border-2 rounded-xl hidden lg:block" /> -->
|
||||
<div class="flex flex-col xl:gap-1 2xl:gap-18 xl:flex-row xl:items-start">
|
||||
{postData.toc && headings.length > 0 && <TableOfContents headings={headings} />}
|
||||
<div class="mb-5 xl:min-w-full 2xl:min-w-full prose">
|
||||
<PostContent />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{
|
||||
nextPostInSeries && (
|
||||
<a
|
||||
href={`/posts/${nextPostInSeries.id}`}
|
||||
class="button justify-center -mt-8 mb-8 !whitespace-normal text-center flex gap-2 sm:gap-3 items-center"
|
||||
>
|
||||
<span>Next: {nextPostInSeries.data.title}</span>
|
||||
<ChevronsRight class="size-5 hidden sm:block" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
AddendumContent && (
|
||||
<PostAddendum avatarImage={addendumAvatarImage}>
|
||||
<AddendumContent />
|
||||
</PostAddendum>
|
||||
)
|
||||
}
|
||||
{
|
||||
showSeries && series ? (
|
||||
<section>
|
||||
<DividerText text={`${series.title} Series`} />
|
||||
{series.entries.map((seriesPost) => (
|
||||
<PostPreview post={seriesPost} />
|
||||
))}
|
||||
</section>
|
||||
) : prev || next ? (
|
||||
<section>
|
||||
<DividerText text="More Posts" />
|
||||
{prev && <PostPreview post={prev} />}
|
||||
{next && <PostPreview post={next} />}
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
<section>
|
||||
<DividerText text="Comments" />
|
||||
<Comments slug={post.id} client:visible />
|
||||
</section>
|
||||
<ScrollUpButton />
|
||||
</Layout>
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
const getRobotsTxt = (sitemapURL: URL) => `\
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
`
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL('sitemap-index.xml', site)
|
||||
return new Response(getRobotsTxt(sitemapURL))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import rss from '@astrojs/rss'
|
||||
import siteConfig from '~/site.config'
|
||||
import type { AstroGlobal } from 'astro'
|
||||
import { getSortedPosts } from '~/utils'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
const parser = new MarkdownIt()
|
||||
|
||||
// https://docs.astro.build/en/recipes/rss/
|
||||
export async function GET(_context: AstroGlobal) {
|
||||
if (!siteConfig.site) {
|
||||
console.warn(
|
||||
'Site URL is required for RSS feed generation. Skipping RSS feed generation.',
|
||||
)
|
||||
return
|
||||
}
|
||||
const posts = await getSortedPosts()
|
||||
return rss({
|
||||
stylesheet: '/rss.xsl',
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
site: siteConfig.site,
|
||||
items: posts.map((post) => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.published,
|
||||
description: post.data.description,
|
||||
author: post.data.author || siteConfig.author,
|
||||
link: `/posts/${post.id}`,
|
||||
content: sanitizeHtml(parser.render(post.body || ''), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
}),
|
||||
})),
|
||||
trailingSlash: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import { SeriesGroup } from '~/utils'
|
||||
import PageHeader from '~/components/PageHeader.astro'
|
||||
import PostPreview from '~/components/PostPreview.astro'
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const seriesGroup = await SeriesGroup.build()
|
||||
return seriesGroup.collations.map((series) => {
|
||||
return {
|
||||
params: { slug: series.titleSlug },
|
||||
props: { posts: series.entries, seriesTitle: series.title },
|
||||
}
|
||||
})
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
const { posts, seriesTitle } = Astro.props
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Series: ${seriesTitle}`}
|
||||
description={`All posts in the ${seriesTitle} series`}
|
||||
>
|
||||
<div class="mt-2 sm:mt-0">
|
||||
<PageHeader titlePieces={['series', seriesTitle]} />
|
||||
{posts.map((post) => <PostPreview post={post} />)}
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,109 @@
|
||||
import siteConfig from '~/site.config'
|
||||
import { Resvg } from '@resvg/resvg-js'
|
||||
import type { APIContext, InferGetStaticPropsType } from 'astro'
|
||||
import satori, { type SatoriOptions } from 'satori'
|
||||
import { html } from 'satori-html'
|
||||
import { dateString, getSortedPosts, resolveThemeColorStyles } from '~/utils'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// Load the font file as binary data
|
||||
const fontPath = path.resolve(
|
||||
'./node_modules/@expo-google-fonts/jetbrains-mono/400Regular/JetBrainsMono_400Regular.ttf',
|
||||
)
|
||||
const fontData = fs.readFileSync(fontPath) // Reads the file as a Buffer
|
||||
|
||||
const avatarPath = path.resolve(siteConfig.socialCardAvatarImage)
|
||||
let avatarData: Buffer | undefined
|
||||
let avatarBase64: string | undefined
|
||||
if (
|
||||
fs.existsSync(avatarPath) &&
|
||||
(path.extname(avatarPath).toLowerCase() === '.jpg' ||
|
||||
path.extname(avatarPath).toLowerCase() === '.jpeg')
|
||||
) {
|
||||
avatarData = fs.readFileSync(avatarPath)
|
||||
avatarBase64 = `data:image/jpeg;base64,${avatarData.toString('base64')}`
|
||||
}
|
||||
|
||||
const defaultTheme =
|
||||
siteConfig.themes.default === 'auto'
|
||||
? siteConfig.themes.include[0]
|
||||
: siteConfig.themes.default
|
||||
|
||||
const themeStyles = await resolveThemeColorStyles(
|
||||
[defaultTheme],
|
||||
siteConfig.themes.overrides,
|
||||
)
|
||||
const bg = themeStyles[defaultTheme]?.background
|
||||
const fg = themeStyles[defaultTheme]?.foreground
|
||||
const accent = themeStyles[defaultTheme]?.accent
|
||||
|
||||
if (!bg || !fg || !accent) {
|
||||
throw new Error(`Theme ${defaultTheme} does not have required colors`)
|
||||
}
|
||||
|
||||
const ogOptions: SatoriOptions = {
|
||||
// debug: true,
|
||||
fonts: [
|
||||
{
|
||||
data: fontData,
|
||||
name: 'JetBrains Mono',
|
||||
style: 'normal',
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
height: 630,
|
||||
width: 1200,
|
||||
}
|
||||
|
||||
const markup = (title: string, pubDate: string | undefined, author: string) =>
|
||||
html(`<div tw="flex flex-col max-w-full justify-center h-full bg-[${bg}] text-[${fg}] p-12">
|
||||
<div style="border-width: 12px; border-radius: 80px;" tw="flex items-center max-w-full p-8 border-[${accent}]/30">
|
||||
${
|
||||
avatarBase64
|
||||
? `<div tw="flex flex-col justify-center items-center w-1/3 h-100">
|
||||
<img src="${avatarBase64}" tw="flex w-full rounded-full border-[${accent}]/30" />
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
<div tw="flex flex-1 flex-col max-w-full justify-center items-center">
|
||||
${pubDate ? `<p tw="text-3xl max-w-full text-[${accent}]">${pubDate}</p>` : ''}
|
||||
<h1 tw="text-6xl my-14 text-center leading-snug">${title}</h1>
|
||||
${author !== title ? `<p tw="text-4xl text-[${accent}]">${author}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`)
|
||||
|
||||
type Props = InferGetStaticPropsType<typeof getStaticPaths>
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const { pubDate, title, author } = context.props as Props
|
||||
const svg = await satori(markup(title, pubDate, author) as ReactNode, ogOptions)
|
||||
const png = new Resvg(svg).render().asPng()
|
||||
return new Response(png, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getSortedPosts()
|
||||
return posts
|
||||
.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: {
|
||||
pubDate: post.data.published ? dateString(post.data.published) : undefined,
|
||||
title: post.data.title,
|
||||
author: post.data.author || siteConfig.author,
|
||||
},
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
params: { slug: '__default' },
|
||||
props: { pubDate: undefined, title: siteConfig.title, author: siteConfig.author },
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import Layout from '~/layouts/Layout.astro'
|
||||
import { TagsGroup } from '~/utils'
|
||||
import Pagination from '~/components/Pagination.astro'
|
||||
import siteConfig from '~/site.config'
|
||||
import PostPreview from '~/components/PostPreview.astro'
|
||||
import PageHeader from '~/components/PageHeader.astro'
|
||||
|
||||
// Note: Pagination like '/', '/2', '/3' only works with spread param like [...page]
|
||||
export const getStaticPaths = (async ({ paginate }) => {
|
||||
const tagsGroup = await TagsGroup.build()
|
||||
const pages = tagsGroup.collations.flatMap((tags) => {
|
||||
// Use flatMap to lift the posts for each tag into a single array
|
||||
return paginate(tags.entries.reverse(), {
|
||||
props: { tagTitle: tags.title },
|
||||
params: { tag: tags.titleSlug },
|
||||
pageSize: siteConfig.pageSize,
|
||||
})
|
||||
})
|
||||
return pages
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
const { page, tagTitle } = Astro.props
|
||||
const pageTitle =
|
||||
`Tag: ${tagTitle}` + (page.currentPage > 1 ? ` - Page ${page.currentPage}` : '')
|
||||
---
|
||||
|
||||
<Layout title={pageTitle} description={`All posts tagged with ${tagTitle}`}>
|
||||
<div class="mt-2 sm:mt-0">
|
||||
<PageHeader titlePieces={['tags', tagTitle]} />
|
||||
{page.data.map((post) => <PostPreview post={post} />)}
|
||||
<Pagination
|
||||
prevLink={page.url.prev ? encodeURI(page.url.prev) : undefined}
|
||||
prevText="Newer Posts"
|
||||
nextLink={page.url.next ? encodeURI(page.url.next) : undefined}
|
||||
nextText="Older Posts"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Plugin } from 'unified'
|
||||
import type { Root } from 'hast'
|
||||
|
||||
const plugin: Plugin<[], Root> = () => {
|
||||
return function transformer(tree) {
|
||||
visit(tree, 'element', (el) => {
|
||||
if (el.tagName === 'img') {
|
||||
const alt = el.properties?.alt
|
||||
if (alt && typeof alt === 'string' && alt.endsWith('#pixelated')) {
|
||||
el.properties['data-pixelated'] = true
|
||||
el.properties.alt = alt.substring(0, alt.length - '#pixelated'.length).trim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -0,0 +1,34 @@
|
||||
import type * as hast from 'hast'
|
||||
import type { RehypePlugin } from '@astrojs/markdown-remark'
|
||||
import { h } from 'hastscript'
|
||||
|
||||
export const rehypeTitleFigure: RehypePlugin = (_options?) => {
|
||||
function buildFigure(el: hast.Element) {
|
||||
const title = `${el.properties?.title || ''}`
|
||||
if (!title) return el
|
||||
const figure = h('figure', [h('img', { ...el.properties }), h('figcaption', title)])
|
||||
return figure
|
||||
}
|
||||
function isElement(content: hast.RootContent): content is hast.Element {
|
||||
return content.type === 'element'
|
||||
}
|
||||
function transformTree(node: hast.Root | hast.Element) {
|
||||
if (node.children) {
|
||||
node.children = node.children.map((child) => {
|
||||
if (isElement(child)) {
|
||||
if (child.tagName === 'img') {
|
||||
return buildFigure(child)
|
||||
} else {
|
||||
transformTree(child) // Recursively process child nodes
|
||||
}
|
||||
}
|
||||
return child
|
||||
})
|
||||
}
|
||||
}
|
||||
return function (tree: hast.Root) {
|
||||
transformTree(tree) // Start the recursive transformation
|
||||
}
|
||||
}
|
||||
|
||||
export default rehypeTitleFigure
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PhrasingContent, Root } from 'mdast'
|
||||
import { toString as mdastToString } from 'mdast-util-to-string'
|
||||
import type { Plugin } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { AdmonitionType } from '~/types'
|
||||
import { h as _h, type Properties } from 'hastscript'
|
||||
import type { Paragraph as P } from 'mdast'
|
||||
|
||||
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
|
||||
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
|
||||
const { properties, tagName } = _h(el, attrs)
|
||||
return {
|
||||
children,
|
||||
data: { hName: tagName, hProperties: properties },
|
||||
type: 'paragraph',
|
||||
}
|
||||
}
|
||||
|
||||
// Supported admonition types
|
||||
const Admonitions = new Set<AdmonitionType>([
|
||||
'tip',
|
||||
'note',
|
||||
'important',
|
||||
'caution',
|
||||
'warning',
|
||||
])
|
||||
|
||||
/** Checks if a string is a supported admonition type. */
|
||||
function isAdmonition(s: string): s is AdmonitionType {
|
||||
return Admonitions.has(s as AdmonitionType)
|
||||
}
|
||||
|
||||
export const remarkAdmonitions: Plugin<[], Root> = () => (tree) => {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (!parent || index === undefined || node.type !== 'containerDirective') return
|
||||
|
||||
const admonitionType = node.name
|
||||
if (!isAdmonition(admonitionType)) return
|
||||
|
||||
let title: string = admonitionType
|
||||
let titleNode: PhrasingContent[] = [{ type: 'text', value: title }]
|
||||
|
||||
// Check if there's a custom title
|
||||
const firstChild = node.children[0]
|
||||
if (
|
||||
firstChild?.type === 'paragraph' &&
|
||||
firstChild.data &&
|
||||
'directiveLabel' in firstChild.data &&
|
||||
firstChild.children.length > 0
|
||||
) {
|
||||
titleNode = firstChild.children
|
||||
title = mdastToString(firstChild.children)
|
||||
// The first paragraph contains a custom title, we can safely remove it.
|
||||
node.children.splice(0, 1)
|
||||
}
|
||||
|
||||
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
|
||||
const admonition = h(
|
||||
'aside',
|
||||
{
|
||||
'aria-label': title,
|
||||
class: 'admonition',
|
||||
'data-admonition-type': admonitionType,
|
||||
},
|
||||
[
|
||||
h('p', { class: 'admonition-title', 'aria-hidden': 'true' }, [...titleNode]),
|
||||
h('div', { class: 'admonition-content' }, node.children),
|
||||
],
|
||||
)
|
||||
|
||||
parent.children[index] = admonition
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Root } from 'mdast'
|
||||
import type { Plugin } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { h as _h, type Properties } from 'hastscript'
|
||||
import type { Paragraph as P } from 'mdast'
|
||||
|
||||
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
|
||||
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
|
||||
const { properties, tagName } = _h(el, attrs)
|
||||
return {
|
||||
children,
|
||||
data: { hName: tagName, hProperties: properties },
|
||||
type: 'paragraph',
|
||||
}
|
||||
}
|
||||
|
||||
const remarkCharacterDialogue: Plugin<[{ characters: Record<string, string> }], Root> =
|
||||
(opts) => (tree) => {
|
||||
// Type guard to check if a string is a valid character dialogue key
|
||||
function isCharacterDialogue(s: string): s is keyof typeof opts.characters {
|
||||
return opts.characters.hasOwnProperty(s) && opts.characters[s] !== undefined
|
||||
}
|
||||
|
||||
// Do nothing if no characters are defined
|
||||
if (!opts.characters || Object.keys(opts.characters).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (!parent || index === undefined || node.type !== 'containerDirective') return
|
||||
|
||||
const characterName = node.name
|
||||
if (!isCharacterDialogue(characterName)) return
|
||||
|
||||
const align = node.attributes?.align ?? null
|
||||
const alignClass = align === 'left' || align === 'right' ? ` align-${align}` : ''
|
||||
|
||||
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
|
||||
const admonition = h(
|
||||
'aside',
|
||||
{
|
||||
'aria-label': `Character dialogue: ${characterName}`,
|
||||
class: 'character-dialogue' + alignClass,
|
||||
'data-character': characterName,
|
||||
},
|
||||
[
|
||||
h('img', {
|
||||
class: 'character-dialogue-image',
|
||||
alt: characterName,
|
||||
loading: 'lazy',
|
||||
src: opts.characters[characterName],
|
||||
width: 100,
|
||||
}),
|
||||
h('div', { class: 'character-dialogue-content' }, node.children),
|
||||
],
|
||||
)
|
||||
|
||||
parent.children[index] = admonition
|
||||
})
|
||||
}
|
||||
|
||||
export default remarkCharacterDialogue
|
||||
@@ -0,0 +1,43 @@
|
||||
import type * as mdast from 'mdast'
|
||||
import type { RemarkPlugin } from '@astrojs/markdown-remark'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
const remarkDescription: RemarkPlugin = (options?: { maxChars?: number }) => {
|
||||
const maxChars = (options && options.maxChars) || 200
|
||||
return function (tree, { data }) {
|
||||
function findFirstParagraph(
|
||||
node: mdast.Root | mdast.RootContent,
|
||||
): string | undefined {
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
if (
|
||||
child.type === 'paragraph' &&
|
||||
child.children.length > 0 &&
|
||||
child.children[0].type !== 'image'
|
||||
) {
|
||||
const s = toString(child).trim()
|
||||
if (s.length > 0) {
|
||||
return s
|
||||
}
|
||||
} else {
|
||||
const result = findFirstParagraph(child)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
let description = data.astro?.frontmatter?.description || findFirstParagraph(tree)
|
||||
if (description && data.astro?.frontmatter) {
|
||||
if (description.length > maxChars) {
|
||||
const lastSpace = description.slice(0, maxChars).lastIndexOf(' ')
|
||||
description = description.slice(0, lastSpace) + '…'
|
||||
}
|
||||
data.astro.frontmatter.description = description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default remarkDescription
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Root, Text } from 'mdast'
|
||||
import type { Plugin } from 'unified'
|
||||
import { nameToEmoji, emojiToName } from 'gemoji'
|
||||
import emojiRegex from 'emoji-regex'
|
||||
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||
|
||||
function emojiSpan(emojiLiteral: string, emojiDescription: string): Text {
|
||||
return {
|
||||
type: 'text',
|
||||
value: emojiLiteral,
|
||||
data: {
|
||||
hName: 'span',
|
||||
hProperties: { role: 'img', ariaLabel: emojiDescription.replace(/_/g, ' ') },
|
||||
hChildren: [{ type: 'text', value: emojiLiteral }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to replace emoji shortcodes with their corresponding emoji characters.
|
||||
* It uses the `gemoji` package to map shortcode names to emoji characters.
|
||||
*/
|
||||
const plugin: Plugin<[], Root> = () => (tree) => {
|
||||
findAndReplace(tree, [
|
||||
[
|
||||
/:(\+1|[-\w]+):/g,
|
||||
(_: string, emojiShortcode: string) => {
|
||||
return Object.hasOwn(nameToEmoji, emojiShortcode)
|
||||
? emojiSpan(nameToEmoji[emojiShortcode], emojiShortcode)
|
||||
: false
|
||||
},
|
||||
],
|
||||
[
|
||||
emojiRegex(),
|
||||
(emojiLiteral: string) => {
|
||||
return Object.hasOwn(emojiToName, emojiLiteral)
|
||||
? emojiSpan(emojiLiteral, emojiToName[emojiLiteral])
|
||||
: false
|
||||
},
|
||||
],
|
||||
])
|
||||
}
|
||||
|
||||
export default plugin
|
||||