Compare commits
3 Commits
118b87a28d
...
0f727d6cee
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f727d6cee | |||
|
|
aeebd576f9 | ||
|
|
110e2dd471 |
@@ -1,5 +1,19 @@
|
||||
// @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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/images/blog/placeholder.jpg
Normal file
BIN
public/images/blog/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
28
src/components/ArticleTile.astro
Normal file
28
src/components/ArticleTile.astro
Normal 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>
|
||||
34
src/content/blog/cs/ai-a-vzdelavani.md
Normal file
34
src/content/blog/cs/ai-a-vzdelavani.md
Normal 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í.
|
||||
14
src/content/blog/cs/ctvrta.md
Normal file
14
src/content/blog/cs/ctvrta.md
Normal 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.
|
||||
14
src/content/blog/cs/druha.md
Normal file
14
src/content/blog/cs/druha.md
Normal 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.
|
||||
14
src/content/blog/cs/pata.md
Normal file
14
src/content/blog/cs/pata.md
Normal 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.
|
||||
14
src/content/blog/cs/sesta.md
Normal file
14
src/content/blog/cs/sesta.md
Normal 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.
|
||||
14
src/content/blog/cs/treti.md
Normal file
14
src/content/blog/cs/treti.md
Normal 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.
|
||||
14
src/content/blog/cs/ukazka.md
Normal file
14
src/content/blog/cs/ukazka.md
Normal 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.
|
||||
34
src/content/blog/en/ai-a-vzdelavani.md
Normal file
34
src/content/blog/en/ai-a-vzdelavani.md
Normal 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.
|
||||
14
src/content/blog/en/ukazka.md
Normal file
14
src/content/blog/en/ukazka.md
Normal 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
15
src/content/config.ts
Normal 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
49
src/i18n/ui.ts
Normal 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`;
|
||||
}
|
||||
81
src/layouts/BaseLayout.astro
Normal file
81
src/layouts/BaseLayout.astro
Normal 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 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
23
src/pages/cs/bio.astro
Normal 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 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>
|
||||
52
src/pages/cs/clanky/[slug].astro
Normal file
52
src/pages/cs/clanky/[slug].astro
Normal 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>
|
||||
101
src/pages/cs/clanky/index.astro
Normal file
101
src/pages/cs/clanky/index.astro
Normal 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
139
src/pages/cs/hledat.astro
Normal 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
101
src/pages/cs/index.astro
Normal 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>
|
||||
24
src/pages/cs/search.json.ts
Normal file
24
src/pages/cs/search.json.ts
Normal 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' },
|
||||
});
|
||||
};
|
||||
63
src/pages/en/articles/[slug].astro
Normal file
63
src/pages/en/articles/[slug].astro
Normal 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>
|
||||
111
src/pages/en/articles/index.astro
Normal file
111
src/pages/en/articles/index.astro
Normal 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
23
src/pages/en/bio.astro
Normal 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 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
111
src/pages/en/index.astro
Normal 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
143
src/pages/en/search.astro
Normal 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>
|
||||
30
src/pages/en/search.json.ts
Normal file
30
src/pages/en/search.json.ts
Normal 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' },
|
||||
});
|
||||
};
|
||||
@@ -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
138
src/styles/global.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user