Compare commits

...

38 Commits

Author SHA1 Message Date
43bc02959f Merge pull request 'Add Czech bio content' (#25) from master into production
All checks were successful
Deploy / deploy (push) Successful in 7s
Reviewed-on: #25
2026-03-11 14:15:34 +01:00
Vladimír Duša
bbc21734bb Add Czech bio content
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 14:15:00 +01:00
b35f8db390 Merge pull request 'Add English bio content' (#24) from master into production
All checks were successful
Deploy / deploy (push) Successful in 7s
Reviewed-on: #24
2026-03-11 14:12:51 +01:00
Vladimír Duša
0412d031be Add English bio content
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 14:12:22 +01:00
5cf7179d27 Merge pull request 'Disable Astro's default locale redirect to allow custom language detection' (#23) from master into production
All checks were successful
Deploy / deploy (push) Successful in 7s
Reviewed-on: #23
2026-03-11 13:21:38 +01:00
Vladimír Duša
9644229a40 Disable Astro's default locale redirect to allow custom language detection
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 13:20:38 +01:00
9b9ab6bacf Merge pull request 'Detect browser language on root and redirect to /cs/ or /en/' (#22) from master into production
All checks were successful
Deploy / deploy (push) Successful in 7s
Reviewed-on: #22
2026-03-11 13:06:04 +01:00
Vladimír Duša
0cc447c113 Detect browser language on root and redirect to /cs/ or /en/
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 13:03:48 +01:00
8a9255da5a Merge pull request 'Update blog content' (#21) from master into production
All checks were successful
Deploy / deploy (push) Successful in 7s
Reviewed-on: #21
2026-03-11 11:22:49 +01:00
Vladimír Duša
9272a8085c Update blog content 2026-03-11 11:22:09 +01:00
0f727d6cee Merge pull request 'master' (#20) from master into production
All checks were successful
Deploy / deploy (push) Successful in 7s
Reviewed-on: #20
2026-03-11 11:18:57 +01:00
Vladimír Duša
aeebd576f9 Use single small placeholder image 2026-03-11 11:15:20 +01:00
Vladimír Duša
110e2dd471 Add full project source 2026-03-11 09:11:13 +01:00
118b87a28d Merge pull request 'Update README.md' (#19) from master into production
All checks were successful
Deploy / deploy (push) Successful in 6s
Reviewed-on: #19
2026-03-11 09:05:12 +01:00
51e3984077 Update README.md 2026-03-11 09:04:57 +01:00
90b547a6df Merge pull request 'Update README.md' (#18) from master into production
Some checks failed
Deploy / deploy (push) Failing after 0s
Reviewed-on: #18
2026-03-11 08:57:46 +01:00
2399c5cdd9 Update README.md 2026-03-11 08:57:33 +01:00
a9d4af3e26 Merge pull request 'Update README.md' (#17) from master into production
Some checks failed
Deploy / deploy (push) Failing after 0s
Reviewed-on: #17
2026-03-11 08:51:14 +01:00
51dbab82bc Update README.md 2026-03-11 08:50:33 +01:00
15f4e0c70f Merge pull request 'Update README.md' (#16) from master into production
Some checks failed
Deploy / deploy (push) Failing after 0s
Reviewed-on: #16
2026-03-11 08:47:28 +01:00
496ac5a87d Update README.md 2026-03-11 08:47:15 +01:00
99a7a1de71 Merge pull request 'Replace actions/checkout with git directly' (#15) from master into production
Some checks failed
Deploy / deploy (push) Failing after 0s
Reviewed-on: #15
2026-03-11 08:45:26 +01:00
Vladimír Duša
3f43ae29fe Replace actions/checkout with git directly 2026-03-11 08:45:10 +01:00
92a30eab78 Merge pull request 'Update README.md' (#14) from master into production
Some checks failed
Deploy / deploy (push) Failing after 1s
Reviewed-on: #14
2026-03-11 08:44:05 +01:00
58e2e1ef0a Update README.md 2026-03-11 08:43:52 +01:00
497ae0ac06 Merge pull request 'Debug deploy step' (#13) from master into production
All checks were successful
Deploy / deploy (push) Successful in 12s
Reviewed-on: #13
2026-03-11 08:38:38 +01:00
Vladimír Duša
99514ac170 Debug deploy step 2026-03-11 08:38:22 +01:00
1fef03ac60 Merge pull request 'Fix deploy path' (#12) from master into production
All checks were successful
Deploy / deploy (push) Successful in 12s
Reviewed-on: #12
2026-03-11 08:33:57 +01:00
Vladimír Duša
3b38c0fb7a Fix deploy path 2026-03-11 08:33:35 +01:00
4d7c273358 Merge pull request 'Add debug output to deploy step' (#11) from master into production
All checks were successful
Deploy / deploy (push) Successful in 14s
Reviewed-on: #11
2026-03-11 08:32:57 +01:00
Vladimír Duša
40b55b3836 Add debug output to deploy step 2026-03-11 08:32:20 +01:00
8fb30b0fcf Merge pull request 'master' (#10) from master into production
All checks were successful
Deploy / deploy (push) Successful in 13s
Reviewed-on: #10
2026-03-11 08:30:06 +01:00
Vladimír Duša
a94d4ffdb9 Rewrite workflow to use GITHUB_WORKSPACE 2026-03-11 08:28:03 +01:00
Vladimír Duša
9cff62faba Add mkdir -p in deploy step 2026-03-11 08:24:43 +01:00
9a2f9ccb86 Merge pull request 'Replace rsync with cp in deploy step' (#9) from master into production
Some checks failed
Deploy / deploy (push) Failing after 11s
Reviewed-on: #9
2026-03-11 08:23:13 +01:00
Vladimír Duša
d324220e51 Replace rsync with cp in deploy step 2026-03-11 08:22:55 +01:00
806be5cd2a Merge pull request 'Use full path for rsync' (#8) from master into production
Some checks failed
Deploy / deploy (push) Failing after 10s
Reviewed-on: #8
2026-03-11 08:17:48 +01:00
Vladimír Duša
496df0606a Use full path for rsync 2026-03-11 08:17:34 +01:00
25 changed files with 1305 additions and 27 deletions

View File

@@ -10,17 +10,18 @@ jobs:
steps:
- name: Checkout
run: |
if [ -d /var/lib/act_runner/dusa.cz ]; then
cd /var/lib/act_runner/dusa.cz && git fetch origin && git reset --hard origin/production
if [ -d .git ]; then
git fetch origin production
git reset --hard origin/production
else
git clone https://git.dusa.cz/vdusa/dusa.cz.git /var/lib/act_runner/dusa.cz && cd /var/lib/act_runner/dusa.cz && git checkout production
git clone --branch production https://git.dusa.cz/vdusa/dusa.cz.git .
fi
- name: Build
run: |
cd /var/lib/act_runner/dusa.cz
npm ci
npm run build
run: npm ci && npm run build
- name: Deploy
run: rsync -a --delete /var/lib/act_runner/dusa.cz/dist/ /var/www/dusa.cz/
run: |
mkdir -p /var/www/dusa.cz
rm -rf /var/www/dusa.cz/*
cp -a dist/. /var/www/dusa.cz/

View File

@@ -1,5 +1,5 @@
# Astro Starter Kit: Minimal
test
```sh
npm create astro@latest -- --template minimal
```

View File

@@ -1,5 +1,20 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({});
export default defineConfig({
site: 'https://dusa.cz',
vite: {
plugins: [tailwindcss()],
},
integrations: [sitemap()],
i18n: {
defaultLocale: 'cs',
locales: ['cs', 'en'],
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: false,
},
},
});

View File

@@ -9,6 +9,11 @@
"astro": "astro"
},
"dependencies": {
"astro": "^5.17.1"
"@astrojs/sitemap": "^3.7.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"astro": "^5.17.1",
"fuse.js": "^7.1.0",
"tailwindcss": "^4.2.1"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,28 @@
---
interface Props {
title: string;
href: string;
image: string;
description?: string;
class?: string;
eager?: boolean;
tags?: string[];
}
const { title, href, image, description, class: className = '', eager = false, tags = [] } = Astro.props;
---
<a href={href} class={`article-tile ${className}`} data-tags={tags.join(',')}>
<img src={image} alt={title} loading={eager ? 'eager' : 'lazy'} fetchpriority={eager ? 'high' : 'auto'} />
<div class="tile-overlay">
{tags.length > 0 && (
<div class="tile-tags">
{tags.map((tag, i) => (
<span>{tag}{i < tags.length - 1 && <span class="tile-tag-sep"> · </span>}</span>
))}
</div>
)}
<p class="tile-title">{title}</p>
{description && <p class="tile-description">{description}</p>}
</div>
</a>

View File

@@ -0,0 +1,14 @@
---
title: Ukázkový článek
date: 2024-03-01
description: Toto je ukázkový článek pro testování vzhledu kachlí na webu.
image: /images/blog/placeholder.jpg
draft: false
tags: [AI, Technologie]
---
Sem přijde text článku. Článek může obsahovat libovolný Markdown obsah.
## Podnadpis
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

View File

@@ -0,0 +1,14 @@
---
title: Sample Article
date: 2024-03-01
description: This is a sample article for testing the tile appearance on the web.
image: /images/blog/placeholder.jpg
draft: false
tags: [AI, Technology]
---
This is the article text. Articles can contain any Markdown content.
## Subheading
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

15
src/content/config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.date(),
description: z.string(),
image: z.string(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };

49
src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,49 @@
export const languages = { cs: 'CS', en: 'EN' };
export const defaultLang = 'cs' as const;
export const ui = {
cs: {
'nav.articles': 'Články',
'nav.bio': 'Bio',
'nav.search': 'Hledat',
'filter.all': 'Vše',
'article.back': '← Všechny články',
'article.no-translation': 'Tento článek není dostupný v angličtině. Zobrazujeme českou verzi.',
'search.placeholder': 'Hledat v článcích…',
'search.empty': 'Žádné výsledky.',
'search.results.one': 'výsledek',
'search.results.few': 'výsledky',
'search.results.many': 'výsledků',
'empty.articles': 'Zatím žádné články.',
},
en: {
'nav.articles': 'Articles',
'nav.bio': 'Bio',
'nav.search': 'Search',
'filter.all': 'All',
'article.back': '← All articles',
'article.no-translation': 'This article is not available in English. Showing the Czech version.',
'search.placeholder': 'Search articles…',
'search.empty': 'No results.',
'search.results.one': 'result',
'search.results.few': 'results',
'search.results.many': 'results',
'empty.articles': 'No articles yet.',
},
} as const;
export type Lang = keyof typeof ui;
export function useTranslations(lang: Lang) {
return function t(key: keyof typeof ui[typeof defaultLang]) {
return (ui[lang] as Record<string, string>)[key] ?? (ui[defaultLang] as Record<string, string>)[key];
};
}
export function getArticlesPath(lang: Lang) {
return lang === 'cs' ? `/${lang}/clanky` : `/${lang}/articles`;
}
export function getSearchPath(lang: Lang) {
return lang === 'cs' ? `/${lang}/hledat` : `/${lang}/search`;
}

View File

@@ -0,0 +1,81 @@
---
import '../styles/global.css';
import { useTranslations, getArticlesPath, getSearchPath } from '../i18n/ui';
import type { Lang } from '../i18n/ui';
interface Props {
title: string;
description?: string;
lang: Lang;
alternateUrl?: string;
}
const { title, description = 'Osobní web Vladimíra Duši', lang, alternateUrl } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const t = useTranslations(lang);
const csUrl = lang === 'cs' ? Astro.url.pathname : (alternateUrl ?? '/cs/');
const enUrl = lang === 'en' ? Astro.url.pathname : (alternateUrl ?? '/en/');
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<title>{title === 'Vladimír Duša' ? title : `${title} — Vladimír Duša`}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body class="min-h-screen flex flex-col">
<header class="flex items-center justify-between px-8 pt-8 pb-6">
<a href={`/${lang}/`} class="site-name text-black no-underline hover:opacity-60 transition-opacity">
Vladimír&nbsp;Duša
</a>
<nav class="flex items-center gap-8">
<a
href={getArticlesPath(lang)}
class="text-sm uppercase tracking-widest text-gray-500 hover:text-black transition-colors"
>
{t('nav.articles')}
</a>
<a
href={`/${lang}/bio`}
class="text-sm uppercase tracking-widest text-gray-500 hover:text-black transition-colors"
>
{t('nav.bio')}
</a>
<a
href={getSearchPath(lang)}
aria-label={t('nav.search')}
class="text-gray-400 hover:text-black transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
</a>
<div class="lang-switcher">
<a href={csUrl} class:list={['', { active: lang === 'cs' }]}>CS</a>
<span class="lang-switcher-sep">·</span>
<a href={enUrl} class:list={['', { active: lang === 'en' }]}>EN</a>
</div>
</nav>
</header>
<main class="flex-1">
<slot />
</main>
<footer class="px-8 py-8 mt-16 border-t border-gray-100">
<div class="flex items-center justify-between text-xs uppercase tracking-widest text-gray-400">
<span>© {new Date().getFullYear()} Vladimír Duša</span>
<div class="flex gap-6">
<a href={getArticlesPath(lang)} class="hover:text-black transition-colors">{t('nav.articles')}</a>
<a href={`/${lang}/bio`} class="hover:text-black transition-colors">{t('nav.bio')}</a>
</div>
</div>
</footer>
</body>
</html>

22
src/pages/cs/bio.astro Normal file
View File

@@ -0,0 +1,22 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const lang = 'cs';
---
<BaseLayout title="Bio" description="Kdo je Vladimír Duša" lang={lang} alternateUrl="/en/bio">
<div class="max-w-xl mx-auto px-8 py-16">
<h1 class="site-name text-lg mb-12">Vladimír&nbsp;Duša</h1>
<div class="text-gray-700 leading-relaxed space-y-5 text-sm">
<p>
Vývojář ve dne, táta tří zvídavých dětí muž mé nejlepší ženy celý den. Jmenuji se Vladimír a žiju v příjemném chaosu, kde se mezi debugováním kódu řeší, proč je obloha modrá, jak fungují černé díry a jestli se dá matematika naučit hrou. Protože věříme, že zvídavost je lepší základ než osnovy, vzděláváme naše děti doma a upřímně, ony učí nás stejně tolik, jako my je.
</p>
<p>
Jako vývojář mě odjakživa bavilo samotné psaní kódu ten moment, kdy stroj začne poslouchat. Dnes mi tuhle radost stále víc bere z ruky AI. Je to fascinující posun, nicméně mi to přijde trochu líto. AI mě zajímá stejně jako věda a matematika. Rád poznávám nová území - jak ta virtuální, mentální tak i svět jako takový.
</p>
<p>
Na tomhle webu ukládám své myšlenky. Kontaktovat mne můžete na <span style="unicode-bidi:bidi-override; direction:rtl">zc.asud@rimidalv</span>
</p>
</div>
</div>
</BaseLayout>

View File

@@ -0,0 +1,52 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { useTranslations } from '../../../i18n/ui';
const lang = 'cs';
const t = useTranslations(lang);
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const csPosts = posts.filter(p => p.id.startsWith('cs/'));
return csPosts.map(post => {
const baseSlug = post.id.replace(/^cs\//, '').replace(/\.md$/, '');
return {
params: { slug: baseSlug },
props: { post },
};
});
}
const { post } = Astro.props;
const { slug } = Astro.params;
const { Content } = await post.render();
---
<BaseLayout
title={post.data.title}
description={post.data.description}
lang={lang}
alternateUrl={`/en/articles/${slug}`}
>
<article class="max-w-2xl mx-auto px-8 pb-20">
<div class="mb-10 -mx-8">
<img
src={post.data.image}
alt={post.data.title}
class="w-full max-h-96 object-cover"
/>
</div>
<p class="text-xs uppercase tracking-widest text-gray-400 mb-3">
{post.data.date.toLocaleDateString('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
<h1 class="text-3xl font-light mb-4 leading-snug">{post.data.title}</h1>
<p class="text-gray-500 mb-10 text-base leading-relaxed">{post.data.description}</p>
<div class="prose prose-neutral max-w-none">
<Content />
</div>
<a href="/cs/clanky" class="inline-block mt-12 text-xs uppercase tracking-widest text-gray-400 hover:text-black transition-colors">
{t('article.back')}
</a>
</article>
</BaseLayout>

View File

@@ -0,0 +1,101 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import ArticleTile from '../../../components/ArticleTile.astro';
import { getCollection } from 'astro:content';
import { useTranslations } from '../../../i18n/ui';
const lang = 'cs';
const t = useTranslations(lang);
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.filter(p => p.id.startsWith('cs/'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const allTags = [...new Set(posts.flatMap(p => p.data.tags))].sort();
---
<BaseLayout title="Články" description="Všechny články Vladimíra Duši" lang={lang} alternateUrl="/en/articles/">
<div class="px-8 pb-12">
{allTags.length > 0 && (
<div class="tag-filter" id="tag-filter">
<button class="tag-filter-btn active" data-tag="all">{t('filter.all')}</button>
{allTags.map((tag) => (
<>
<span class="tag-filter-sep">·</span>
<button class="tag-filter-btn" data-tag={tag}>{tag}</button>
</>
))}
</div>
)}
<div class="article-grid" id="article-grid">
{posts.map((post, i) => {
const baseSlug = post.id.replace(/^cs\//, '').replace(/\.md$/, '');
return (
<ArticleTile
title={post.data.title}
href={`/cs/clanky/${baseSlug}`}
image={post.data.image}
description={post.data.description}
tags={post.data.tags}
class={i % 6 === 0 ? 'tile-featured-left' : i % 6 === 5 ? 'tile-featured-right' : 'tile-regular'}
eager={i < 3}
/>
);
})}
</div>
{posts.length === 0 && (
<p class="text-gray-400 text-sm py-16">{t('empty.articles')}</p>
)}
</div>
</BaseLayout>
<script>
const buttons = document.querySelectorAll<HTMLButtonElement>('.tag-filter-btn');
const tiles = document.querySelectorAll<HTMLAnchorElement>('.article-tile');
function applyFilter(tag: string) {
buttons.forEach(btn => btn.classList.toggle('active', btn.dataset.tag === tag));
tiles.forEach(tile => {
const tileTags = (tile.dataset.tags ?? '').split(',').filter(Boolean);
const matches = tag === 'all' || tileTags.includes(tag);
tile.classList.toggle('is-filtered', !matches);
});
const url = new URL(window.location.href);
if (tag === 'all') url.searchParams.delete('tag');
else url.searchParams.set('tag', tag);
history.replaceState(null, '', url.toString());
}
const initialTag = new URLSearchParams(window.location.search).get('tag') ?? 'all';
applyFilter(initialTag);
buttons.forEach(btn => {
btn.addEventListener('click', () => applyFilter(btn.dataset.tag ?? 'all'));
});
</script>
<style>
.article-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 260px;
gap: 10px;
}
@media (max-width: 768px) {
.article-grid {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 200px;
}
}
@media (max-width: 480px) {
.article-grid {
grid-template-columns: 1fr;
grid-auto-rows: 220px;
}
}
</style>

139
src/pages/cs/hledat.astro Normal file
View File

@@ -0,0 +1,139 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { useTranslations } from '../../i18n/ui';
const lang = 'cs';
const t = useTranslations(lang);
---
<BaseLayout title="Hledat" description="Vyhledávání v článcích" lang={lang} alternateUrl="/en/search">
<div class="max-w-2xl mx-auto px-8 py-12">
<div class="mb-10">
<input
id="search-input"
type="search"
placeholder={t('search.placeholder')}
autocomplete="off"
class="w-full border-b border-gray-300 focus:border-black outline-none py-3 text-lg bg-transparent placeholder-gray-400 transition-colors"
/>
</div>
<div id="search-status" class="text-sm text-gray-400 mb-6 hidden"></div>
<ul id="search-results" class="space-y-6"></ul>
<p id="search-empty" class="text-sm text-gray-400 hidden">{t('search.empty')}</p>
</div>
</BaseLayout>
<script>
import Fuse from 'fuse.js';
type Article = {
slug: string;
title: string;
description: string;
image: string;
date: string;
body: string;
};
const input = document.getElementById('search-input') as HTMLInputElement;
const resultsList = document.getElementById('search-results') as HTMLUListElement;
const statusEl = document.getElementById('search-status') as HTMLParagraphElement;
const emptyEl = document.getElementById('search-empty') as HTMLParagraphElement;
let fuse: Fuse<Article> | null = null;
async function init() {
const res = await fetch('/cs/search.json');
const articles: Article[] = await res.json();
fuse = new Fuse(articles, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'description', weight: 1.5 },
{ name: 'body', weight: 1 },
],
threshold: 0.2,
ignoreLocation: true,
includeMatches: true,
minMatchCharLength: 2,
});
const params = new URLSearchParams(window.location.search);
const q = params.get('q') ?? '';
if (q) {
input.value = q;
search(q);
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('cs-CZ', {
day: 'numeric', month: 'long', year: 'numeric',
});
}
function renderResults(results: Fuse.FuseResult<Article>[]) {
resultsList.innerHTML = '';
emptyEl.classList.add('hidden');
statusEl.classList.remove('hidden');
if (results.length === 0) {
statusEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
return;
}
const count = results.length;
const label = count === 1 ? 'výsledek' : count < 5 ? 'výsledky' : 'výsledků';
statusEl.textContent = `${count} ${label}`;
for (const result of results) {
const { slug, title, description, image, date } = result.item;
const li = document.createElement('li');
li.innerHTML = `
<a href="/cs/clanky/${slug}" class="flex gap-4 group items-start">
<img
src="${image}"
alt="${title}"
loading="lazy"
class="w-20 h-14 object-cover rounded-lg flex-shrink-0 grayscale group-hover:grayscale-0 transition-all duration-300"
/>
<div>
<p class="text-xs text-gray-400 mb-1">${formatDate(date)}</p>
<p class="font-medium group-hover:underline leading-snug">${title}</p>
<p class="text-sm text-gray-500 mt-0.5 line-clamp-2">${description}</p>
</div>
</a>
`;
resultsList.appendChild(li);
}
}
function search(query: string) {
if (!fuse) return;
const q = query.trim();
if (q.length < 2) {
resultsList.innerHTML = '';
statusEl.classList.add('hidden');
emptyEl.classList.add('hidden');
return;
}
const results = fuse.search(q);
renderResults(results);
const url = new URL(window.location.href);
url.searchParams.set('q', q);
history.replaceState(null, '', url.toString());
}
let debounceTimer: ReturnType<typeof setTimeout>;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(input.value), 200);
});
init();
</script>

101
src/pages/cs/index.astro Normal file
View File

@@ -0,0 +1,101 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArticleTile from '../../components/ArticleTile.astro';
import { getCollection } from 'astro:content';
import { useTranslations } from '../../i18n/ui';
const lang = 'cs';
const t = useTranslations(lang);
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.filter(p => p.id.startsWith('cs/'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const allTags = [...new Set(posts.flatMap(p => p.data.tags))].sort();
---
<BaseLayout title="Vladimír Duša" description="Osobní web Vladimíra Duši" lang={lang} alternateUrl="/en/">
{posts.length === 0 ? (
<div class="px-8 py-16 text-gray-400 text-sm">{t('empty.articles')}</div>
) : (
<div class="px-8 pb-12">
{allTags.length > 0 && (
<div class="tag-filter" id="tag-filter">
<button class="tag-filter-btn active" data-tag="all">{t('filter.all')}</button>
{allTags.map((tag) => (
<>
<span class="tag-filter-sep">·</span>
<button class="tag-filter-btn" data-tag={tag}>{tag}</button>
</>
))}
</div>
)}
<div class="article-grid">
{posts.map((post, i) => {
const baseSlug = post.id.replace(/^cs\//, '').replace(/\.md$/, '');
return (
<ArticleTile
title={post.data.title}
href={`/cs/clanky/${baseSlug}`}
image={post.data.image}
description={post.data.description}
tags={post.data.tags}
class={i % 6 === 0 ? 'tile-featured-left' : i % 6 === 5 ? 'tile-featured-right' : 'tile-regular'}
eager={i < 3}
/>
);
})}
</div>
</div>
)}
</BaseLayout>
<script>
const buttons = document.querySelectorAll<HTMLButtonElement>('.tag-filter-btn');
const tiles = document.querySelectorAll<HTMLAnchorElement>('.article-tile');
function applyFilter(tag: string) {
buttons.forEach(btn => btn.classList.toggle('active', btn.dataset.tag === tag));
tiles.forEach(tile => {
const tileTags = (tile.dataset.tags ?? '').split(',').filter(Boolean);
const matches = tag === 'all' || tileTags.includes(tag);
tile.classList.toggle('is-filtered', !matches);
});
const url = new URL(window.location.href);
if (tag === 'all') url.searchParams.delete('tag');
else url.searchParams.set('tag', tag);
history.replaceState(null, '', url.toString());
}
const initialTag = new URLSearchParams(window.location.search).get('tag') ?? 'all';
applyFilter(initialTag);
buttons.forEach(btn => {
btn.addEventListener('click', () => applyFilter(btn.dataset.tag ?? 'all'));
});
</script>
<style>
.article-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 260px;
gap: 10px;
}
@media (max-width: 768px) {
.article-grid {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 200px;
}
}
@media (max-width: 480px) {
.article-grid {
grid-template-columns: 1fr;
grid-auto-rows: 220px;
}
}
</style>

View File

@@ -0,0 +1,24 @@
import { getCollection } from 'astro:content';
import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.filter(p => p.id.startsWith('cs/'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const index = posts.map((post) => {
const slug = post.id.replace(/^cs\//, '').replace(/\.md$/, '');
return {
slug,
title: post.data.title,
description: post.data.description,
image: post.data.image,
date: post.data.date.toISOString().split('T')[0],
body: post.body,
};
});
return new Response(JSON.stringify(index), {
headers: { 'Content-Type': 'application/json' },
});
};

View File

@@ -0,0 +1,63 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { useTranslations } from '../../../i18n/ui';
const lang = 'en';
const t = useTranslations(lang);
export async function getStaticPaths() {
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const csPosts = allPosts.filter(p => p.id.startsWith('cs/'));
const enPosts = allPosts.filter(p => p.id.startsWith('en/'));
// Generate paths for ALL CS articles (EN articles without CS counterpart are also included)
return csPosts.map(csPost => {
const baseSlug = csPost.id.replace(/^cs\//, '').replace(/\.md$/, '');
const enPost = enPosts.find(p => p.id.replace(/^en\//, '').replace(/\.md$/, '') === baseSlug);
return {
params: { slug: baseSlug },
props: { post: enPost ?? csPost, hasTranslation: !!enPost },
};
});
}
const { post, hasTranslation } = Astro.props;
const { slug } = Astro.params;
const { Content } = await post.render();
const dateLocale = hasTranslation ? 'en-GB' : 'cs-CZ';
---
<BaseLayout
title={post.data.title}
description={post.data.description}
lang={lang}
alternateUrl={`/cs/clanky/${slug}`}
>
<article class="max-w-2xl mx-auto px-8 pb-20">
{!hasTranslation && (
<div class="mb-8 px-4 py-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
{t('article.no-translation')}
</div>
)}
<div class="mb-10 -mx-8">
<img
src={post.data.image}
alt={post.data.title}
class="w-full max-h-96 object-cover"
/>
</div>
<p class="text-xs uppercase tracking-widest text-gray-400 mb-3">
{post.data.date.toLocaleDateString(dateLocale, { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
<h1 class="text-3xl font-light mb-4 leading-snug">{post.data.title}</h1>
<p class="text-gray-500 mb-10 text-base leading-relaxed">{post.data.description}</p>
<div class="prose prose-neutral max-w-none">
<Content />
</div>
<a href="/en/articles" class="inline-block mt-12 text-xs uppercase tracking-widest text-gray-400 hover:text-black transition-colors">
{t('article.back')}
</a>
</article>
</BaseLayout>

View File

@@ -0,0 +1,111 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import ArticleTile from '../../../components/ArticleTile.astro';
import { getCollection } from 'astro:content';
import { useTranslations } from '../../../i18n/ui';
const lang = 'en';
const t = useTranslations(lang);
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const csPosts = allPosts
.filter(p => p.id.startsWith('cs/'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const enPosts = allPosts.filter(p => p.id.startsWith('en/'));
// For each CS post, find EN version if it exists
const articlesForDisplay = csPosts.map(csPost => {
const baseSlug = csPost.id.replace(/^cs\//, '').replace(/\.md$/, '');
const enPost = enPosts.find(p => p.id.replace(/^en\//, '').replace(/\.md$/, '') === baseSlug);
return {
post: enPost ?? csPost,
slug: baseSlug,
hasTranslation: !!enPost,
};
});
const allTags = [...new Set(articlesForDisplay.flatMap(a => a.post.data.tags))].sort();
---
<BaseLayout title="Articles" description="All articles by Vladimír Duša" lang={lang} alternateUrl="/cs/clanky/">
<div class="px-8 pb-12">
{allTags.length > 0 && (
<div class="tag-filter" id="tag-filter">
<button class="tag-filter-btn active" data-tag="all">{t('filter.all')}</button>
{allTags.map((tag) => (
<>
<span class="tag-filter-sep">·</span>
<button class="tag-filter-btn" data-tag={tag}>{tag}</button>
</>
))}
</div>
)}
<div class="article-grid" id="article-grid">
{articlesForDisplay.map(({ post, slug, hasTranslation }, i) => (
<ArticleTile
title={post.data.title}
href={`/en/articles/${slug}`}
image={post.data.image}
description={post.data.description}
tags={post.data.tags}
class={i % 6 === 0 ? 'tile-featured-left' : i % 6 === 5 ? 'tile-featured-right' : 'tile-regular'}
eager={i < 3}
/>
))}
</div>
{articlesForDisplay.length === 0 && (
<p class="text-gray-400 text-sm py-16">{t('empty.articles')}</p>
)}
</div>
</BaseLayout>
<script>
const buttons = document.querySelectorAll<HTMLButtonElement>('.tag-filter-btn');
const tiles = document.querySelectorAll<HTMLAnchorElement>('.article-tile');
function applyFilter(tag: string) {
buttons.forEach(btn => btn.classList.toggle('active', btn.dataset.tag === tag));
tiles.forEach(tile => {
const tileTags = (tile.dataset.tags ?? '').split(',').filter(Boolean);
const matches = tag === 'all' || tileTags.includes(tag);
tile.classList.toggle('is-filtered', !matches);
});
const url = new URL(window.location.href);
if (tag === 'all') url.searchParams.delete('tag');
else url.searchParams.set('tag', tag);
history.replaceState(null, '', url.toString());
}
const initialTag = new URLSearchParams(window.location.search).get('tag') ?? 'all';
applyFilter(initialTag);
buttons.forEach(btn => {
btn.addEventListener('click', () => applyFilter(btn.dataset.tag ?? 'all'));
});
</script>
<style>
.article-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 260px;
gap: 10px;
}
@media (max-width: 768px) {
.article-grid {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 200px;
}
}
@media (max-width: 480px) {
.article-grid {
grid-template-columns: 1fr;
grid-auto-rows: 220px;
}
}
</style>

22
src/pages/en/bio.astro Normal file
View File

@@ -0,0 +1,22 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const lang = 'en';
---
<BaseLayout title="Bio" description="Who is Vladimír Duša" lang={lang} alternateUrl="/cs/bio">
<div class="max-w-xl mx-auto px-8 py-16">
<h1 class="site-name text-lg mb-12">Vladimír&nbsp;Duša</h1>
<div class="text-gray-700 leading-relaxed space-y-5 text-sm">
<p>
Developer by day, father of three curious kids, and my wonderful wife's husban all day long. My name is Vladimír and I live in a pleasant chaos where, between debugging code, we tackle questions like why the sky is blue, how black holes work, and whether math can be learned through play. Because we believe curiosity is a better foundation than a curriculum, we homeschool our children — and honestly, they teach us just as much as we teach them.
</p>
<p>
As a developer, I've always loved the act of writing code itself — that moment when the machine starts to listen. These days, AI is increasingly taking that joy out of my hands. It's a fascinating shift, though I find it a little bittersweet. AI interests me just as much as science and mathematics. I enjoy exploring new territories — virtual ones, mental ones, and the world at large.
</p>
<p>
On this site I store my thoughts. You can reach me at <span style="unicode-bidi:bidi-override; direction:rtl">zc.asud@rimidalv</span>
</p>
</div>
</div>
</BaseLayout>

111
src/pages/en/index.astro Normal file
View File

@@ -0,0 +1,111 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArticleTile from '../../components/ArticleTile.astro';
import { getCollection } from 'astro:content';
import { useTranslations } from '../../i18n/ui';
const lang = 'en';
const t = useTranslations(lang);
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const csPosts = allPosts
.filter(p => p.id.startsWith('cs/'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const enPosts = allPosts.filter(p => p.id.startsWith('en/'));
// For each CS post, find EN version if it exists
const articlesForDisplay = csPosts.map(csPost => {
const baseSlug = csPost.id.replace(/^cs\//, '').replace(/\.md$/, '');
const enPost = enPosts.find(p => p.id.replace(/^en\//, '').replace(/\.md$/, '') === baseSlug);
return {
post: enPost ?? csPost,
slug: baseSlug,
hasTranslation: !!enPost,
};
});
const allTags = [...new Set(articlesForDisplay.flatMap(a => a.post.data.tags))].sort();
---
<BaseLayout title="Vladimír Duša" description="Personal website of Vladimír Duša" lang={lang} alternateUrl="/cs/">
{articlesForDisplay.length === 0 ? (
<div class="px-8 py-16 text-gray-400 text-sm">{t('empty.articles')}</div>
) : (
<div class="px-8 pb-12">
{allTags.length > 0 && (
<div class="tag-filter" id="tag-filter">
<button class="tag-filter-btn active" data-tag="all">{t('filter.all')}</button>
{allTags.map((tag) => (
<>
<span class="tag-filter-sep">·</span>
<button class="tag-filter-btn" data-tag={tag}>{tag}</button>
</>
))}
</div>
)}
<div class="article-grid">
{articlesForDisplay.map(({ post, slug, hasTranslation }, i) => (
<ArticleTile
title={post.data.title}
href={`/en/articles/${slug}`}
image={post.data.image}
description={post.data.description}
tags={post.data.tags}
class={i % 6 === 0 ? 'tile-featured-left' : i % 6 === 5 ? 'tile-featured-right' : 'tile-regular'}
eager={i < 3}
/>
))}
</div>
</div>
)}
</BaseLayout>
<script>
const buttons = document.querySelectorAll<HTMLButtonElement>('.tag-filter-btn');
const tiles = document.querySelectorAll<HTMLAnchorElement>('.article-tile');
function applyFilter(tag: string) {
buttons.forEach(btn => btn.classList.toggle('active', btn.dataset.tag === tag));
tiles.forEach(tile => {
const tileTags = (tile.dataset.tags ?? '').split(',').filter(Boolean);
const matches = tag === 'all' || tileTags.includes(tag);
tile.classList.toggle('is-filtered', !matches);
});
const url = new URL(window.location.href);
if (tag === 'all') url.searchParams.delete('tag');
else url.searchParams.set('tag', tag);
history.replaceState(null, '', url.toString());
}
const initialTag = new URLSearchParams(window.location.search).get('tag') ?? 'all';
applyFilter(initialTag);
buttons.forEach(btn => {
btn.addEventListener('click', () => applyFilter(btn.dataset.tag ?? 'all'));
});
</script>
<style>
.article-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 260px;
gap: 10px;
}
@media (max-width: 768px) {
.article-grid {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 200px;
}
}
@media (max-width: 480px) {
.article-grid {
grid-template-columns: 1fr;
grid-auto-rows: 220px;
}
}
</style>

143
src/pages/en/search.astro Normal file
View File

@@ -0,0 +1,143 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { useTranslations } from '../../i18n/ui';
const lang = 'en';
const t = useTranslations(lang);
---
<BaseLayout title="Search" description="Search articles" lang={lang} alternateUrl="/cs/hledat">
<div class="max-w-2xl mx-auto px-8 py-12">
<div class="mb-10">
<input
id="search-input"
type="search"
placeholder={t('search.placeholder')}
autocomplete="off"
class="w-full border-b border-gray-300 focus:border-black outline-none py-3 text-lg bg-transparent placeholder-gray-400 transition-colors"
/>
</div>
<div id="search-status" class="text-sm text-gray-400 mb-6 hidden"></div>
<ul id="search-results" class="space-y-6"></ul>
<p id="search-empty" class="text-sm text-gray-400 hidden">{t('search.empty')}</p>
</div>
</BaseLayout>
<script>
import Fuse from 'fuse.js';
type Article = {
slug: string;
title: string;
description: string;
image: string;
date: string;
body: string;
lang?: string;
};
const input = document.getElementById('search-input') as HTMLInputElement;
const resultsList = document.getElementById('search-results') as HTMLUListElement;
const statusEl = document.getElementById('search-status') as HTMLParagraphElement;
const emptyEl = document.getElementById('search-empty') as HTMLParagraphElement;
let fuse: Fuse<Article> | null = null;
async function init() {
const res = await fetch('/en/search.json');
const articles: Article[] = await res.json();
fuse = new Fuse(articles, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'description', weight: 1.5 },
{ name: 'body', weight: 1 },
],
threshold: 0.2,
ignoreLocation: true,
includeMatches: true,
minMatchCharLength: 2,
});
const params = new URLSearchParams(window.location.search);
const q = params.get('q') ?? '';
if (q) {
input.value = q;
search(q);
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric',
});
}
function renderResults(results: Fuse.FuseResult<Article>[]) {
resultsList.innerHTML = '';
emptyEl.classList.add('hidden');
statusEl.classList.remove('hidden');
if (results.length === 0) {
statusEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
return;
}
const count = results.length;
const label = count === 1 ? 'result' : 'results';
statusEl.textContent = `${count} ${label}`;
for (const result of results) {
const { slug, title, description, image, date, lang } = result.item;
const csOnlyBadge = lang === 'cs'
? `<span class="text-xs text-amber-600 ml-1">(Czech only)</span>`
: '';
const li = document.createElement('li');
li.innerHTML = `
<a href="/en/articles/${slug}" class="flex gap-4 group items-start">
<img
src="${image}"
alt="${title}"
loading="lazy"
class="w-20 h-14 object-cover rounded-lg flex-shrink-0 grayscale group-hover:grayscale-0 transition-all duration-300"
/>
<div>
<p class="text-xs text-gray-400 mb-1">${formatDate(date)}</p>
<p class="font-medium group-hover:underline leading-snug">${title}${csOnlyBadge}</p>
<p class="text-sm text-gray-500 mt-0.5 line-clamp-2">${description}</p>
</div>
</a>
`;
resultsList.appendChild(li);
}
}
function search(query: string) {
if (!fuse) return;
const q = query.trim();
if (q.length < 2) {
resultsList.innerHTML = '';
statusEl.classList.add('hidden');
emptyEl.classList.add('hidden');
return;
}
const results = fuse.search(q);
renderResults(results);
const url = new URL(window.location.href);
url.searchParams.set('q', q);
history.replaceState(null, '', url.toString());
}
let debounceTimer: ReturnType<typeof setTimeout>;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(input.value), 200);
});
init();
</script>

View File

@@ -0,0 +1,30 @@
import { getCollection } from 'astro:content';
import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const csPosts = allPosts
.filter(p => p.id.startsWith('cs/'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const enPosts = allPosts.filter(p => p.id.startsWith('en/'));
// For each CS article, use EN version if available, otherwise fall back to CS
const index = csPosts.map((csPost) => {
const baseSlug = csPost.id.replace(/^cs\//, '').replace(/\.md$/, '');
const enPost = enPosts.find(p => p.id.replace(/^en\//, '').replace(/\.md$/, '') === baseSlug);
const post = enPost ?? csPost;
return {
slug: baseSlug,
title: post.data.title,
description: post.data.description,
image: post.data.image,
date: post.data.date.toISOString().split('T')[0],
body: post.body,
lang: enPost ? 'en' : 'cs',
};
});
return new Response(JSON.stringify(index), {
headers: { 'Content-Type': 'application/json' },
});
};

View File

@@ -1,17 +1,16 @@
---
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
</body>
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<script is:inline>
var lang = (navigator.language || navigator.languages && navigator.languages[0] || '').toLowerCase();
window.location.replace(lang.startsWith('cs') ? '/cs/' : '/en/');
</script>
<noscript>
<meta http-equiv="refresh" content="0;url=/en/" />
</noscript>
</head>
<body></body>
</html>

138
src/styles/global.css Normal file
View File

@@ -0,0 +1,138 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
body {
background-color: #ffffff;
color: #111111;
-webkit-font-smoothing: antialiased;
}
/* Letter-spaced name in header */
.site-name {
letter-spacing: 0.18em;
font-weight: 400;
text-transform: uppercase;
font-size: 0.9rem;
}
/* Article tile grid classes (must be global — applied to child component roots) */
.tile-featured-left,
.tile-featured-right {
grid-column: span 2;
}
@media (max-width: 480px) {
.tile-featured-left,
.tile-featured-right {
grid-column: span 1;
}
}
/* Article tile grayscale → color on hover */
.article-tile {
position: relative;
overflow: hidden;
display: block;
border-radius: 0.75rem;
}
.article-tile img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(100%);
transform: scale(1);
transition: filter 0.4s ease, transform 0.5s ease;
}
.article-tile:hover img {
filter: grayscale(0%);
transform: scale(1.06);
}
.article-tile .tile-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.25rem 1rem 1rem;
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.5) 50%, transparent 100%);
color: white;
}
.article-tile .tile-tags {
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
opacity: 0.75;
margin-bottom: 0.3rem;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
.article-tile .tile-title {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.35;
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
.article-tile .tile-description {
font-size: 0.75rem;
opacity: 0.85;
margin-top: 0.2rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
/* Filtered-out tile */
.article-tile.is-filtered {
display: none;
}
/* Language switcher */
.lang-switcher { display: flex; align-items: center; gap: 0; margin-left: 1.5rem; padding-left: 1.5rem; border-left: 1px solid #e5e5e5; }
.lang-switcher a { font-size: 0.65rem; letter-spacing: 0.15em; text-transform: uppercase; color: #aaa; text-decoration: none; transition: color 0.2s; }
.lang-switcher a:hover { color: #111; }
.lang-switcher a.active { color: #111; border-bottom: 1px solid #111; }
.lang-switcher-sep { color: #ddd; font-size: 0.75rem; margin: 0 0.4rem; }
/* Tag filter bar */
.tag-filter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
padding: 0 0 1.5rem;
}
.tag-filter-btn {
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #aaa;
background: none;
border: none;
border-bottom: 1px solid transparent;
cursor: pointer;
padding: 0.15rem 0;
transition: color 0.2s, border-color 0.2s;
}
.tag-filter-btn:hover {
color: #111;
}
.tag-filter-btn.active {
color: #111;
border-bottom-color: #111;
}
.tag-filter-sep {
color: #ccc;
font-size: 0.75rem;
margin: 0 0.65rem;
user-select: none;
}