Compare commits

..

3 Commits

Author SHA1 Message Date
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
30 changed files with 1422 additions and 19 deletions

View File

@@ -1,5 +1,19 @@
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; 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,
},
},
});

View File

@@ -9,6 +9,11 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "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,34 @@
---
title: Jak AI mění způsob, jakým se učíme
date: 2024-11-15
description: Umělá inteligence proniká do škol i domácností. Co to znamená pro budoucnost vzdělávání a roli učitele?
image: /images/blog/placeholder1.jpg
draft: false
tags: [AI, vzdělávání]
---
Ještě před pěti lety bylo personalizované učení s pomocí umělé inteligence doménou sci-fi románů. Dnes si žáci základních škol nechávají vysvětlovat matematiku od chatbotů a studenti vysokých škol používají AI jako prvního rádce při psaní seminárních prací.
## Tradiční model vzdělávání pod tlakem
Tradiční model vzdělávání stál na předpokladu, že učitel je hlavním zdrojem poznání. Tento model fungoval staletí — a funguje dodnes. AI ho neboří, ale výrazně přetváří. Znalosti jsou nyní dostupné okamžitě, kdykoli a kýmkoli. Otázka přestává být *co se naučit* a stává se *jak se to naučit* a *proč*.
## Co AI ve vzdělávání umí dnes
Personalizované učení přizpůsobené tempu každého žáka bylo vždy snem pedagogů. Dnes je to technicky možné. Nástroje jako Khan Academy nebo Duolingo využívají AI k tomu, aby sledovaly pokrok, identifikovaly mezery ve znalostech a přizpůsobovaly obsah v reálném čase.
Vedle toho AI pomáhá s:
- **Okamžitou zpětnou vazbou** — žák nemusí čekat na opravenou písemku
- **Překladem a jazykovou asistencí** — přístup ke znalostem bez jazykové bariéry
- **Generováním vysvětlení** — složitý pojem lze vysvětlit deseti různými způsoby, dokud jeden nezapadne
## Největší riziko: myšlení na autopilotu
Největším rizikem není, že AI nahradí učitele. Největším rizikem je, že přestaneme rozvíjet schopnost hluboce přemýšlet, protože odpověď je vždy na dosah ruky. Když nemusíme zápasit s problémem, nevznikají ty nejcennější spoje v mozku.
Dobrý učitel to ví odjakživa — nechá žáka chvíli bojovat, než zasáhne. AI zatím tuto citlivost postrádá.
## Role učitele se mění, ne mizí
Učitel budoucnosti bude méně přenašečem informací a více průvodcem. Bude pomáhat žákům orientovat se v záplavě dat, kriticky hodnotit zdroje a nacházet vlastní motivaci k učení. To jsou schopnosti, které AI doplní, ale nenahradí.

View File

@@ -0,0 +1,14 @@
---
title: Čtvrtý článek
date: 2023-12-10
description: Čtvrtý ukázkový článek s homeschooling tematikou.
image: /images/blog/placeholder4.jpg
draft: false
tags: [vzdělávání]
---
Sem přijde text článku. Nějaké Slovo.
## Podnadpis
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

View File

@@ -0,0 +1,14 @@
---
title: Druhý článek
date: 2024-02-15
description: Toto je druhý článek na webu.
image: /images/blog/placeholder2.jpg
draft: false
tags: [vzdělávání]
---
Sem přijde text druhého článku.
## Podnadpis
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

View File

@@ -0,0 +1,14 @@
---
title: Pátý článek
date: 2023-11-05
description: Pátý ukázkový článek.
image: /images/blog/placeholder5.jpg
draft: false
tags: [AI, názor]
---
Sem přijde text pátého článku.
## Podnadpis
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

View File

@@ -0,0 +1,14 @@
---
title: Šestý článek
date: 2023-10-01
description: Šestý ukázkový článek.
image: /images/blog/placeholder6.jpg
draft: false
tags: [vzdělávání]
---
Sem přijde text šestého článku.
## Podnadpis
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

View File

@@ -0,0 +1,14 @@
---
title: Třetí článek
date: 2024-01-20
description: Třetí ukázkový článek.
image: /images/blog/placeholder3.jpg
draft: false
tags: [názor]
---
Sem přijde text třetího článku.
## Podnadpis
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

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/placeholder1.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,34 @@
---
title: How AI Is Changing the Way We Learn
date: 2024-11-15
description: Artificial intelligence is making its way into schools and homes alike. What does this mean for the future of education and the role of teachers?
image: /images/blog/placeholder1.jpg
draft: false
tags: [AI, Education]
---
Just five years ago, personalised learning powered by artificial intelligence was the stuff of science fiction. Today, primary school pupils have maths explained to them by chatbots, and university students treat AI as their first port of call when writing essays.
## Traditional Education Under Pressure
The traditional model of education rested on the assumption that the teacher is the primary source of knowledge. This model worked for centuries — and it still does. AI doesn't destroy it, but it significantly reshapes it. Knowledge is now available instantly, anytime, to anyone. The question is no longer *what to learn* but *how to learn it* and *why*.
## What AI Can Do in Education Today
Personalised learning tailored to each student's pace has always been a dream for educators. Today it's technically possible. Tools like Khan Academy or Duolingo use AI to track progress, identify knowledge gaps, and adapt content in real time.
Beyond that, AI helps with:
- **Instant feedback** — students don't have to wait for a marked test
- **Translation and language support** — access to knowledge without language barriers
- **Generating explanations** — a complex concept can be explained ten different ways until one clicks
## The Biggest Risk: Thinking on Autopilot
The biggest risk isn't that AI will replace teachers. The biggest risk is that we'll stop developing the ability to think deeply, because the answer is always within reach. When we don't have to wrestle with a problem, the most valuable neural connections never form.
Good teachers have always known this — they let a student struggle for a while before stepping in. AI still lacks this sensitivity.
## The Teacher's Role Is Changing, Not Disappearing
The teacher of the future will be less of an information transmitter and more of a guide. They will help students navigate the flood of data, critically evaluate sources, and find their own motivation to learn. These are abilities that AI will complement, but never replace.

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>

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

@@ -0,0 +1,23 @@
---
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>
Jsem [váš popis — např. vývojář, designér, pedagog...] se zájmem o [témata, kterým se věnujete].
Na tomto webu sdílím své myšlenky, zkušenosti a postřehy z oblastí, které mě baví a zajímají.
</p>
<p>
[Druhý odstavec — co vás formovalo, co děláte profesně nebo osobně, co vás žene dopředu.]
</p>
<p>
[Třetí odstavec — jak vás mohou čtenáři kontaktovat, co zde najdou, nebo co je na tomto blogu jinak.]
</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>

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

@@ -0,0 +1,23 @@
---
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>
I am [your description — e.g. developer, designer, educator...] with an interest in [topics you focus on].
On this website I share my thoughts, experiences and observations from areas that interest and excite me.
</p>
<p>
[Second paragraph — what shaped you, what you do professionally or personally, what drives you forward.]
</p>
<p>
[Third paragraph — how readers can contact you, what they will find here, or what makes this blog different.]
</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,3 @@
--- ---
return Astro.redirect('/cs/');
--- ---
<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>
</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;
}