prepare for blog posts

This commit is contained in:
jpelc committed 2026-09-10 16:42:21 +02:00
1 parent 3c08326a32
commit b1017f29c2
25 files changed
+917 -1039

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/cmjs/articles/
/cmjs/pictures/
+11 -8
View File
@@ -4,7 +4,7 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
## How it works
1. The browser traverses `cmjs/articles/` via nginx JSON autoindex — discovering authors, categories, and article files.
1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors, year categories, and article files. Articles may be direct Markdown files or use the nested `author/category/post/post.md` layout; nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback.
2. Each `.md` file's YAML frontmatter is parsed to build the article list.
3. Clicking an article fetches the Markdown file and renders it with [marked.js](https://marked.js.org/).
4. Hash-based routing handles navigation — back/forward works without page reloads.
@@ -54,17 +54,19 @@ cmjs/
│ └── article.html # Full article view layout
├── articles/
│ └── author/ # One directory per author
│ └── category/ # One directory per category
│ └── slug.md # Article file
│ └── expa-YYYY/ # Main expedition-year category
│ └── slug/ # One directory per post
│ └── slug.md
└── pictures/ # Article images (referenced via image: frontmatter field)
```
## Adding an article
1. Create the author and category directories if they don't exist: `cmjs/articles/author/category/`
2. Create a `.md` file inside: `cmjs/articles/author/category/my-article.md`
3. Add a frontmatter block at the top
4. Write your content — it will appear in the list automatically
1. Create the author, year, and post directories if they don't exist: `cmjs/articles/author/expa-YYYY/my-article/`
2. Create the Markdown file inside: `cmjs/articles/author/expa-YYYY/my-article/my-article.md`
3. Optional tag markers can sit beside the Markdown file as `tag-name.tag`.
4. Add a frontmatter block at the top
5. Write your content — it will appear in the list automatically
`author` and `category` are derived from the directory path — no frontmatter fields needed for them.
@@ -136,7 +138,8 @@ The body text below the frontmatter is rendered as markdown and shown as the bio
| `#author/alice/page/2` | All articles by alice, page 2 |
| `#category/linux` | All articles in "linux", page 1 |
| `#category/linux/page/2` | All articles in "linux", page 2 |
| `#alice/linux/my-article` | Single article at `cmjs/articles/alice/linux/my-article.md` |
| `#alice/linux/my-article` | Single legacy article at `cmjs/articles/alice/linux/my-article.md` |
| `#alice/expa-2024/my-article/my-article` | Single nested article at `cmjs/articles/alice/expa-2024/my-article/my-article.md` |
Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
-7
View File
@@ -1,7 +0,0 @@
---
title: Alice Smith
github: https://github.com/alice
twitter: https://twitter.com/alice
---
Linux enthusiast and Python developer. I cover everything from shell scripting to beginner programming guides.
@@ -1,59 +0,0 @@
---
title: Getting Started with Linux
date: 2026-06-10
description: A beginner's guide to the Linux operating system and its core concepts.
draft: false
---
Linux is a free and open-source operating system kernel first released by Linus Torvalds in 1991. Today it powers everything from smartphones to supercomputers.
## What is a distribution?
A Linux distribution (distro) bundles the kernel with software, a package manager, and a desktop environment. Popular choices include:
- **Ubuntu** — beginner-friendly, large community
- **Fedora** — cutting-edge packages, sponsored by Red Hat
- **Arch Linux** — minimal base, user-assembled
- **Debian** — stability-focused, the base for many other distros
## The terminal
The terminal is your most powerful tool. A few commands to get started:
```bash
# Print current directory
pwd
# List files
ls -lah
# Change directory
cd /home/user
# Copy a file
cp source.txt destination.txt
```
## File system layout
Lorem ipsum dolor sit amet, consectetur adipiscing elit. The Linux file system follows the Filesystem Hierarchy Standard (FHS):
| Directory | Purpose |
|-----------|---------|
| `/` | Root of the entire file system |
| `/home` | User home directories |
| `/etc` | System configuration files |
| `/var` | Variable data (logs, caches) |
| `/usr` | User-installed programs and libraries |
## Package management
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. On Debian/Ubuntu systems, packages are managed with `apt`:
```bash
sudo apt update # refresh package index
sudo apt install vim # install a package
sudo apt upgrade # upgrade all packages
```
> The best way to learn Linux is to use it daily. Break things, fix them, and you'll learn more than any book can teach.
@@ -1,69 +0,0 @@
---
title: Understanding Linux File Permissions
date: 2026-05-22
description: Learn how Linux controls access to files and directories with users, groups, and permission bits.
draft: false
---
Every file and directory in Linux has an owner, a group, and a set of permission bits. Understanding these is essential for keeping a system secure.
## Reading permission strings
Run `ls -l` and you'll see output like this:
```
-rwxr-xr-- 1 alice developers 4096 May 22 09:14 deploy.sh
```
The first column breaks down as:
- `-` — file type (`d` for directory, `l` for symlink)
- `rwx` — owner permissions (read, write, execute)
- `r-x` — group permissions
- `r--` — other (world) permissions
## Changing permissions with chmod
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.
```bash
# Give owner execute permission
chmod u+x script.sh
# Remove write from group and others
chmod go-w file.txt
# Set exact permissions with octal notation
chmod 644 config.ini # rw-r--r--
chmod 755 bin/ # rwxr-xr-x
```
## Changing ownership with chown
```bash
# Change owner
chown alice file.txt
# Change owner and group
chown alice:developers project/
# Recursive change
chown -R www-data:www-data /var/www/html
```
## Special permission bits
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris:
| Bit | Name | Effect on files | Effect on directories |
|-----|------|-----------------|-----------------------|
| `4` | setuid | Run as file owner | — |
| `2` | setgid | Run as file group | New files inherit group |
| `1` | sticky | — | Only owner can delete |
```bash
# Set sticky bit on shared directory
chmod +t /tmp/shared
```
> When in doubt, use the least privilege necessary. A file that only needs to be read should not be executable.
@@ -1,79 +0,0 @@
---
title: Python for Beginners
date: 2026-06-01
description: An introduction to Python programming with practical examples and clear explanations.
draft: false
---
Python is a high-level, general-purpose programming language known for its readable syntax and large standard library. It's a great first language.
## Variables and types
Python is dynamically typed — you don't declare types explicitly:
```python
name = "Alice"
age = 30
height = 1.72
is_admin = True
```
## Control flow
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
```python
# Conditionals
if age >= 18:
print("Adult")
elif age >= 13:
print("Teenager")
else:
print("Child")
# Loops
for i in range(5):
print(i)
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
```
## Functions
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua:
```python
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", "Hi")) # Hi, Bob!
```
## Lists and dictionaries
```python
# List — ordered, mutable
numbers = [3, 1, 4, 1, 5, 9]
numbers.append(2)
numbers.sort()
# Dictionary — key-value pairs
user = {
"name": "Alice",
"age": 30,
"roles": ["admin", "editor"]
}
print(user["name"]) # Alice
```
## Why Python?
- Clean, readable syntax
- Huge standard library
- Active ecosystem (pip, PyPI)
- Used in web, data science, automation, AI
> Write code that is easy to read. You'll spend far more time reading code than writing it.
-7
View File
@@ -1,7 +0,0 @@
---
title: Bob Johnson
github: https://github.com/bob
website: https://bob.example.com
---
DevOps engineer with a passion for containers, automation, and clean infrastructure. I write about Docker, nginx, and everything in between.
@@ -1,92 +0,0 @@
---
title: Introduction to Docker
date: 2026-06-14
description: Understand containers, images, and how Docker simplifies application deployment.
draft: false
---
Docker is a platform for building, shipping, and running applications in containers. A container packages your code together with its dependencies, so it runs the same everywhere.
## Containers vs virtual machines
| | Container | Virtual Machine |
|---|-----------|-----------------|
| Start time | Milliseconds | Minutes |
| Size | Megabytes | Gigabytes |
| Isolation | Process-level | Hardware-level |
| Overhead | Very low | Higher |
## Core concepts
- **Image** — a read-only template (like a class)
- **Container** — a running instance of an image (like an object)
- **Registry** — a storage and distribution system for images (Docker Hub)
- **Dockerfile** — instructions for building an image
## Basic commands
```bash
# Pull an image
docker pull nginx:alpine
# Run a container
docker run -d -p 8080:80 --name my-nginx nginx:alpine
# List running containers
docker ps
# Stop and remove
docker stop my-nginx
docker rm my-nginx
```
## Writing a Dockerfile
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
```
Build and run it:
```bash
docker build -t my-app:latest .
docker run -d -p 3000:3000 my-app:latest
```
## Docker Compose
Ut enim ad minim veniam for multi-container applications:
```yaml
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
```
```bash
docker compose up -d
docker compose logs -f
docker compose down
```
> Containers solve "works on my machine" by making your machine part of the deliverable.
@@ -1,94 +0,0 @@
---
title: Setting Up an nginx Reverse Proxy
date: 2026-05-30
description: Use nginx as a reverse proxy to route traffic to multiple backend services on a single server.
draft: false
---
A reverse proxy sits in front of your backend services, forwarding client requests to the appropriate server. nginx excels at this role — it's fast, stable, and highly configurable.
## Why use a reverse proxy?
- Route multiple domains/paths to different backends
- Terminate TLS in one place
- Add rate limiting, caching, and access control
- Hide backend server details from clients
## Basic reverse proxy configuration
```nginx
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## Proxying multiple services
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
```nginx
server {
listen 80;
server_name example.com;
# Main app
location / {
proxy_pass http://localhost:3000;
}
# API on a subpath
location /api/ {
proxy_pass http://localhost:4000/;
}
# Static files served directly
location /static/ {
root /var/www;
expires 30d;
}
}
```
## Adding TLS with Certbot
Sed do eiusmod tempor incididunt ut labore:
```bash
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Obtain and install certificate
sudo certbot --nginx -d example.com -d www.example.com
# Certbot auto-renews via systemd timer — verify it
sudo systemctl status certbot.timer
```
## Upstream blocks for load balancing
```nginx
upstream backend {
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
keepalive 32;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
```
> Always set `proxy_set_header X-Forwarded-For` so your application knows the real client IP, not the proxy address.
@@ -1,85 +0,0 @@
---
title: Bash Scripting Basics
date: 2026-06-08
description: Write your first shell scripts to automate repetitive tasks on Linux and macOS.
draft: false
---
A shell script is a plain text file containing a sequence of commands. The shell reads and executes them in order — a simple but powerful form of automation.
## Your first script
```bash
#!/usr/bin/env bash
# The shebang line above tells the OS which interpreter to use
echo "Hello, world!"
```
Save it as `hello.sh`, make it executable, and run it:
```bash
chmod +x hello.sh
./hello.sh
```
## Variables
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
```bash
NAME="Bob"
echo "Hello, $NAME"
# Command substitution
TODAY=$(date +%Y-%m-%d)
echo "Today is $TODAY"
```
## Conditionals and loops
```bash
# If / else
if [ -f "/etc/hosts" ]; then
echo "hosts file exists"
else
echo "hosts file missing"
fi
# For loop over files
for file in *.log; do
echo "Processing $file"
gzip "$file"
done
# While loop
COUNT=0
while [ $COUNT -lt 5 ]; do
echo "Count: $COUNT"
COUNT=$((COUNT + 1))
done
```
## Functions
Ut enim ad minim veniam, quis nostrud exercitation:
```bash
backup() {
local src="$1"
local dst="$2"
cp -r "$src" "$dst/$(basename $src)-$(date +%Y%m%d)"
echo "Backed up $src to $dst"
}
backup /etc/nginx /var/backups
```
## Practical tips
- Always quote variables: `"$VAR"` not `$VAR`
- Use `set -e` to exit on error
- Use `set -u` to catch undefined variables
- Prefer `[[ ]]` over `[ ]` for conditionals in bash
> Automate anything you do more than twice. Your future self will thank you.
+1
View File
@@ -0,0 +1 @@
+166
View File
@@ -0,0 +1,166 @@
{
"schemaVersion": 1,
"tags": [
{
"id": "analogova-astrofotografie",
"label": "Analogová astrofotografie",
"description": "",
"aliases": [],
"active": true
},
{
"id": "astromakers",
"label": "Astromakers",
"description": "",
"aliases": [],
"active": true
},
{
"id": "ccd",
"label": "CCD",
"description": "",
"aliases": [],
"active": true
},
{
"id": "deep-sky",
"label": "Deep-sky",
"description": "",
"aliases": [],
"active": true
},
{
"id": "digitalni-astrofotografie",
"label": "Digitální astrofotografie",
"description": "",
"aliases": [],
"active": true
},
{
"id": "existencialni-otazky",
"label": "Existenciální otázky",
"description": "",
"aliases": [],
"active": true
},
{
"id": "expedicnici",
"label": "Expedičníci",
"description": "",
"aliases": [],
"active": true
},
{
"id": "experimenty",
"label": "Experimenty",
"description": "",
"aliases": [],
"active": true
},
{
"id": "katalog-og",
"label": "Katalog OG",
"description": "",
"aliases": [],
"active": true
},
{
"id": "kometa",
"label": "Kometa",
"description": "",
"aliases": [],
"active": true
},
{
"id": "meziplanetarni-hmota",
"label": "Meziplanetární hmota",
"description": "",
"aliases": [],
"active": true
},
{
"id": "mesic",
"label": "Měsíc",
"description": "",
"aliases": [],
"active": true
},
{
"id": "nezarazene",
"label": "Nezařazené",
"description": "",
"aliases": [],
"active": true
},
{
"id": "nove-objevy",
"label": "Nové objevy",
"description": "",
"aliases": [],
"active": true
},
{
"id": "po-expedici",
"label": "Po expedici",
"description": "",
"aliases": [],
"active": true
},
{
"id": "projekty",
"label": "Projekty",
"description": "",
"aliases": [],
"active": true
},
{
"id": "radioastronomie",
"label": "Rádioastronomie",
"description": "",
"aliases": [],
"active": true
},
{
"id": "scigifoto",
"label": "Scigifoto",
"description": "",
"aliases": [],
"active": true
},
{
"id": "skupiny",
"label": "Skupiny",
"description": "",
"aliases": [],
"active": true
},
{
"id": "spolecenske-aktivity",
"label": "Společenské aktivity",
"description": "",
"aliases": [],
"active": true
},
{
"id": "vedouci",
"label": "Vedoucí",
"description": "",
"aliases": [],
"active": true
},
{
"id": "vysledky-pozorovani",
"label": "Výsledky pozorování",
"description": "",
"aliases": [],
"active": true
},
{
"id": "zivot-na-expedici",
"label": "Život na expedici",
"description": "",
"aliases": [],
"active": true
}
]
}
+1
View File
@@ -68,6 +68,7 @@
.article-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
}
+47 -18
View File
@@ -2,36 +2,53 @@
Article list cards
------------------------------------------------------------------------- */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden; /* clip card-image to rounded corners */
margin-bottom: 1rem;
transition: border-color 0.15s, box-shadow 0.15s;
display: grid;
grid-template-columns: minmax(220px, 34%) minmax(0, 1fr);
gap: 1.75rem;
padding: 1.1rem 0 1.75rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1.75rem;
transition: border-color 0.15s;
}
.card:hover {
border-color: var(--color-text-muted);
box-shadow: var(--shadow-card);
border-color: var(--color-accent);
}
.card--no-image { grid-template-columns: 1fr; }
.card-media {
display: block;
align-self: start;
overflow: hidden;
aspect-ratio: 16 / 10;
background: var(--color-code-bg);
}
/* Preview thumbnail — shown only when article has an image: field */
.card-image {
display: block;
width: 100%;
height: 200px;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.card:hover .card-image { transform: scale(1.025); }
.card-media:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
/* Text content wrapper */
.card-body {
padding: 1.25rem 1.5rem;
padding: 0.1rem 0 0;
}
.card h2 {
margin: 0 0 0.2rem;
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 0.35rem;
font-size: clamp(1.35rem, 2.2vw, 1.8rem);
font-weight: 500;
}
.card h2 a {
@@ -67,20 +84,26 @@
}
.card p {
margin: 0 0 0.6rem;
margin: 1rem 0 0.8rem;
color: var(--color-text-soft);
font-size: 0.95rem;
font-size: 1rem;
line-height: 1.65;
}
/* Author + category row at the bottom of each card */
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.82rem;
}
.tag-list {
display: contents;
}
/* Shared author link — used in cards and article headers */
.author-link {
color: var(--color-text-muted);
@@ -120,6 +143,12 @@
}
@media (max-width: 640px) {
.card-image { height: 150px; }
.card-body { padding: 1rem; }
.card {
grid-template-columns: 1fr;
gap: 0.9rem;
padding-top: 0;
}
.card-media { aspect-ratio: 16 / 9; }
.card-body { padding: 0; }
}
+99
View File
@@ -0,0 +1,99 @@
Source SQL dump: /home/jakub/Documents/expa/web/wp/wordpress/db/migration_dump.sql
Source uploads: /home/jakub/Documents/expa/web/wp/wordpress/html/wp-content/uploads
Exported posts: 509
Skipped records: 1025
Copied media files: 322
Unresolved upload URLs:
http://astronomickaexpedice.cz/wp-content/uploads/2025/03/export-3-export-3-scaled.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/03/export-4-export-4-scaled.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/export-2-export-2.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-1.1-final-1.1-scaled.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-export-final-export.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-nova-uprava-final-nova-uprava.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-pc-final-pc.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/iris-export-6.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/jesle-finale-2-jesle-finale-2-1.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/jeslicky-novy-6-vance-jeslicky-novy-6-vance.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/koule-plna-hvezd-export-1.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/m42-orion-starless-scaled.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/m51-export-3-hdr-sharp-m51-export-3-hdr-sharp-scaled.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/mensi-mensi.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/mesic-jizni-okraj-mesic-export-2-1-rotated.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/orion-novy-pokus-final-4-orion-novy-pokus-final-4-scaled.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/triangulum-hdr-6-triangulum-hdr-6.tif
http://astronomickaexpedice.cz/wp-content/uploads/2025/08/obloha-nad-expou-v-nedeli-pred-obedem-celooblohovka-expa-1024x767.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/08/pacman-4-pacman-4.jpg
http://astronomickaexpedice.cz/wp-content/uploads/2025/08/triangulace-v-triangulu-aneb-jak-jsem-s-pomoci-triangulace-nasel-galaxii-triangulum-triangulace-1024x594.jpeg
https://astronomickaexpedice.cz/wp-content/uploads/2012/06/ngc-457-muzicek1-1024x907.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/expa2012_dusik-1024x681.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/expa2012_dusik_vejce-1024x681.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/img_11062-1024x682.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/p1240331-1024x768.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/p1240437-1024x768.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/pyromanie-300x199.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/smallimg_2165a-300x112.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/sun-300x199.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/v1010cas-300x202.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/v502oph-300x202.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/115-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/130-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/232-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/309-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/310-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/409-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/410-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/412-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/509-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/511-300x157.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/expa-300x199.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/m13-expa1-300x199.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/astrometry2.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/calib-cometa_00041.gif
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/cwcas_lightcurve.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/cwcas_oc.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/etd1.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/exo-chart1.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/exo2.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/formular.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/gif1.gif
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/ha-2-20-zmenseno.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/lrgb_m8_krivka-1024x1019.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/lrgb_m8_krivka.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/m31_single_done_red.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/mereni-komety.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/orlimlhovina-mini.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/referencni.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/screen21.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/sestava-na-spektra.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega-neupraveno.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega-s-prvky.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega-upraveno.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega_opraveno.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_2b_ja.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_2b_peter.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_74b_dienstbier.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_74b_plsek.png
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_74b_siegert_kohl.png
https://astronomickaexpedice.cz/wp-content/uploads/2022/07/v-nektere-vede-se-muze-skryvat-astrofotografie-v-kazde-astrofotografii-se-muze-skryvat-veda-varzralok-1024x831.png
https://astronomickaexpedice.cz/wp-content/uploads/2022/08/lbn-527-lbn527-1024x854.png
https://astronomickaexpedice.cz/wp-content/uploads/2022/08/okno-do-nebe-aneb-porovnani-kvality-oblohy-windowfinal-1-1024x1024.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2022/08/okno-do-nebe-aneb-porovnani-kvality-oblohy-windowfinal-1024x1024.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2023/08/pozorovani-tranzitu-exoplanety-toi-4010-d-img-5285-225x300.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/01/ifn-okolo-polarky-pokus-6-1024x698.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/bode-modry-3-1320x928.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/duse-5-1320x742.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/i-se-starsim-fotakem-lze-vykouzlit-zazraky-krajinka-canon-30d-1024x683.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/i-se-starsim-fotakem-lze-vykouzlit-zazraky-rybi-oko-canon-30d-1024x683.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/m27-cinka-m27-1320x969.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/panorama-export-1320x903.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/vesmirna-zahradka-hotovo-1024x745.jpeg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/zakreslovani-slunce-img-4097-768x1024.jpeg
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/zakreslovani-slunce-img-4105-768x1024.jpeg
https://astronomickaexpedice.cz/wp-content/uploads/2025/09/NGC40-1002x1024.jpg
https://astronomickaexpedice.cz/wp-content/uploads/2025/10/final-1024x686.jpg
https://www.astronomickaexpedice.cz/wp-content/uploads/2016/06/img_9395.jpg
https://www.astronomickaexpedice.cz/wp-content/uploads/2016/07/img_4514blog.jpg
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/cinka-mini.jpg
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/final_ic59_63.jpg
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/heartfinal-mini.jpg
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/zamrzle1.jpg
+135 -79
View File
@@ -1,98 +1,154 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL } from './config.js';
import { BASE_URL, PICTURES_URL } from './config.js';
// Fetch a URL and parse the response as JSON, throwing on non-OK status.
function parseHtmlDirectoryListing(raw) {
const entries = [];
const seen = new Set();
const anchors = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = anchors.exec(raw))) {
const href = match[1].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
if (!href || href.startsWith('../') || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) continue;
const clean = href.split(/[?#]/, 1)[0];
const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop();
if (!name || name === '.' || name === '..' || seen.has(name)) continue;
seen.add(name);
entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file' });
}
return entries;
}
export async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
return res.json();
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const raw = await response.text();
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch { /* HTML autoindex fallback */ }
const entries = parseHtmlDirectoryListing(raw);
if (entries.length || /<(?:title|h1)[^>]*>\s*(?:index of|directory listing)/i.test(raw)) return entries;
throw new Error(`Expected a directory listing at ${url}`);
}
export function asList(value) {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
if (value == null || value === '') return [];
return String(value).split(',').map(item => item.trim()).filter(Boolean);
}
export function resolveMediaUrl(value, articlePath = '') {
if (!value) return '';
let path = String(value).trim();
if (/^(data:|blob:)/i.test(path)) return path;
if (/^https?:\/\//i.test(path)) {
try {
const url = new URL(path);
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
else return path;
} catch { return path; }
}
if (path.startsWith('media/') && articlePath) return new URL(path, articlePath).href;
path = path.replace(/^.*\/wp-content\/uploads\//i, '')
.replace(/^\/?(?:blog\/media|cmjs\/pictures|pictures)\//i, '')
.replace(/^\.\.\/pictures\//i, '');
return new URL(path.replace(/^\//, ''), PICTURES_URL).href;
}
let tagCache = null;
export async function fetchTagRegistry() {
if (tagCache) return tagCache;
try {
const response = await fetch(`${BASE_URL}blog-config/tags.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const registry = await response.json();
tagCache = Array.isArray(registry.tags) ? registry.tags : [];
} catch { tagCache = []; }
return tagCache;
}
// ---------------------------------------------------------------------------
// Article discovery
// Traverses the 3-level directory tree: author → category → article files.
// Results are cached after the first load.
// ---------------------------------------------------------------------------
let articleCache = null;
function isPublished(meta) {
if (meta.status != null) return ['published', 'publish'].includes(String(meta.status).toLowerCase());
return !['true', 'yes', '1'].includes(String(meta.draft ?? '').toLowerCase().trim());
}
function excerpt(content) {
const text = String(content || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/[>#*_~\\]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= 280) return text;
return `${text.slice(0, 277).replace(/\s+\S*$/, '')}…`;
}
export async function discoverArticles() {
if (articleCache) return articleCache;
// Level 1: author directories
const authorEntries = await fetchJson(`${BASE_URL}articles/`);
const authorDirs = authorEntries.filter(e => e.type === 'directory');
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
const categoryLists = await Promise.all(authors.map(async author => {
const entries = await fetchJson(`${BASE_URL}articles/${encodeURIComponent(author.name)}/`);
const directories = entries.filter(entry => entry.type === 'directory' && entry.name !== '_history');
const years = directories.filter(entry => /^expa-20\d{2}$/i.test(entry.name));
return (years.length ? years : directories).map(category => ({ author: author.name, category: category.name }));
}));
// Level 2: category directories (parallel across all authors)
const categoryLists = await Promise.all(
authorDirs.map(async author => {
const entries = await fetchJson(`${BASE_URL}articles/${author.name}/`);
return entries
.filter(e => e.type === 'directory')
.map(cat => ({ author: author.name, category: cat.name }));
})
);
const allCategories = categoryLists.flat();
// Level 3: .md files (parallel across all categories)
const fileLists = await Promise.all(
allCategories.map(async ({ author, category }) => {
const entries = await fetchJson(`${BASE_URL}articles/${author}/${category}/`);
return entries
.filter(e => e.type === 'file' && e.name.endsWith('.md'))
.map(f => ({ author, category, name: f.name }));
})
);
const allFiles = fileLists.flat();
// Level 4: fetch frontmatter and compute reading time for each article (parallel)
const articles = await Promise.all(
allFiles.map(async ({ author, category, name }) => {
const slug = name.replace(/\.md$/, '');
const path = `${BASE_URL}articles/${author}/${category}/${name}`;
const hash = `${author}/${category}/${slug}`;
try {
const res = await fetch(path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.text();
const { meta, content } = parseFrontmatter(raw);
const words = content.trim().split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
return { slug, path, hash, author, category, readingTime, ...meta };
} catch {
return { slug, path, hash, author, category, title: slug };
}
})
);
// Filter drafts — treat 'true', 'yes', '1' (case-insensitive) as draft
// Sort newest-first; articles without a date fall to the end
articleCache = articles
.filter(a => {
const d = String(a.draft ?? '').toLowerCase().trim();
return d !== 'true' && d !== 'yes' && d !== '1';
})
.sort((a, b) => {
if (!a.date && !b.date) return 0;
if (!a.date) return 1;
if (!b.date) return -1;
return b.date.localeCompare(a.date);
});
const sources = (await Promise.all(categoryLists.flat().map(async ({ author, category }) => {
const root = `${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(category)}/`;
const entries = await fetchJson(root);
const direct = entries.filter(entry => entry.type === 'file' && entry.name.endsWith('.md'))
.map(entry => ({ author, category, slug: entry.name.replace(/\.md$/, ''), name: entry.name, root }));
const nested = await Promise.all(entries.filter(entry => entry.type === 'directory' && entry.name !== '_history').map(async post => {
const postRoot = `${root}${encodeURIComponent(post.name)}/`;
const files = (await fetchJson(postRoot)).filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && !entry.name.startsWith('_'));
const selected = files.find(entry => entry.name === 'index.md') || files.find(entry => entry.name === `${post.name}.md`) || (files.length === 1 ? files[0] : null);
return selected ? { author, category, slug: post.name, name: selected.name, root: postRoot } : null;
}));
return [...direct, ...nested.filter(Boolean)];
}))).flat();
const articles = await Promise.all(sources.map(async source => {
const path = `${source.root}${encodeURIComponent(source.name)}`;
try {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = await response.text();
const { meta, content } = parseFrontmatter(raw);
const words = content.trim().split(/\s+/).filter(Boolean).length;
return {
...meta,
author: source.author,
category: source.category,
expa: Number(meta.expa || source.category.replace(/^expa-/i, '')) || null,
slug: source.slug,
path,
hash: `${source.author}/${source.category}/${source.slug}`,
readingTime: Math.max(1, Math.round(words / 200)),
description: meta.description || excerpt(content),
tags: asList(meta.tags),
coverUrl: resolveMediaUrl(meta.cover || meta.image, path),
published: isPublished(meta),
};
} catch { return null; }
}));
articleCache = articles.filter(article => article?.published).sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
return articleCache;
}
// ---------------------------------------------------------------------------
// Author profile
// Tries to fetch articles/{author}/{author}.md — returns parsed profile or null.
// No cache; called at most once per author view navigation.
// ---------------------------------------------------------------------------
export async function findArticle(hash) {
const article = (await discoverArticles()).find(candidate => candidate.hash === hash);
if (!article) throw new Error('Článek nebyl nalezen');
return article;
}
export async function fetchAuthorProfile(author) {
try {
const res = await fetch(`${BASE_URL}articles/${author}/${author}.md`);
if (!res.ok) return null;
const { meta, content } = parseFrontmatter(await res.text());
const response = await fetch(`${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`);
if (!response.ok) return null;
const { meta, content } = parseFrontmatter(await response.text());
return { author, ...meta, content };
} catch {
return null;
}
} catch { return null; }
}
+42 -16
View File
@@ -1,22 +1,48 @@
// Parses Hugo-style YAML frontmatter from a raw markdown string.
// Returns { meta: { key: value, ... }, content: '...' }
// Small YAML-frontmatter reader for the canonical blog schema. It supports
// quoted/unquoted scalars, booleans, numbers and scalar arrays in inline or
// block form. The editor remains the authoritative validator and writer.
function parseScalar(raw) {
const value = raw.trim();
if (!value) return '';
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null' || value === '~') return null;
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
if (value.startsWith('"') && value.endsWith('"')) {
try { return JSON.parse(value); } catch { return value.slice(1, -1); }
}
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
if (value.startsWith('[') && value.endsWith(']')) {
const inside = value.slice(1, -1).trim();
if (!inside) return [];
const values = [];
let token = '';
let quote = '';
for (const character of inside) {
if ((character === '"' || character === "'") && (!quote || quote === character)) quote = quote ? '' : character;
if (character === ',' && !quote) { values.push(parseScalar(token)); token = ''; }
else token += character;
}
values.push(parseScalar(token));
return values;
}
return value;
}
export function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
const meta = {};
match[1].split('\n').forEach(line => {
const colon = line.indexOf(':');
if (colon === -1) return;
const key = line.slice(0, colon).trim();
const rawVal = line.slice(colon + 1).trim();
// Strip surrounding quotes only when they form a matched pair
const val = (rawVal.startsWith('"') && rawVal.endsWith('"')) ||
(rawVal.startsWith("'") && rawVal.endsWith("'"))
? rawVal.slice(1, -1)
: rawVal;
meta[key] = val;
});
let arrayKey = null;
for (const line of match[1].split(/\r?\n/)) {
const item = line.match(/^\s*-\s+(.*)$/);
if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; }
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
if (!field) { arrayKey = null; continue; }
const [, key, rawValue = ''] = field;
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
else { meta[key] = parseScalar(rawValue); arrayKey = null; }
}
return { meta, content: match[2] };
}
+6 -3
View File
@@ -5,14 +5,14 @@
// first = "search" → search results (#search/query)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "category" → category filter, page 1 (#category/linux)
// 2 segments, first = "category" or "tag" → filtered list, page 1
// 3 segments → single article (#author/category/slug)
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2)
import { MARKED_URL } from './config.js';
import { loadTemplates } from './templates.js';
import { loadArticleList, loadAuthorView, loadCategoryView, loadArticle, loadSearch } from './views.js';
import { loadArticleList, loadAuthorView, loadCategoryView, loadTagView, loadArticle, loadSearch } from './views.js';
// Dynamically inject marked.js as a plain global script before the app starts.
function loadScript(url) {
@@ -41,9 +41,12 @@ function router() {
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1]));
else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodeURIComponent(parts[1]));
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1]));
else if (parts.length === 3) loadArticle(parts.map(decodeURIComponent).join('/'));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4) loadArticle(parts.map(decodeURIComponent).join('/'));
else loadArticleList();
}
@@ -62,7 +65,7 @@ function init() {
// Show a top-level error if dependencies fail to load
ready.catch(err => {
document.body.innerHTML = `<p class="error" style="padding:2rem">Failed to initialise: ${err.message}</p>`;
document.body.textContent = `Blog se nepodařilo spustit: ${err.message}`;
});
ready.then(router).catch(() => {});
+239 -400
View File
@@ -1,19 +1,25 @@
import { templates } from './templates.js';
import { discoverArticles, fetchAuthorProfile } from './discovery.js';
import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, resolveMediaUrl } from './discovery.js';
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, PICTURES_URL } from './config.js';
// marked is loaded as a plain global from marked.min.js
import { PICTURES_URL } from './config.js';
const PAGE_SIZE = 20;
let tagLabels = new Map();
async function ensureTags() {
if (!tagLabels.size) tagLabels = new Map((await fetchTagRegistry()).map(tag => [tag.id, tag.label]));
}
function czechDate(value) {
if (!value) return '';
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' }).format(date);
}
// ---------------------------------------------------------------------------
// View helpers — toggle which panel is visible
// ---------------------------------------------------------------------------
export function showListView() {
document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true;
document.title = 'cmjs';
document.title = 'Expediční blogy';
}
export function showArticleView() {
@@ -21,449 +27,282 @@ export function showArticleView() {
document.getElementById('article-view').hidden = false;
}
// Set a data-field element's value, or remove it if there is no value.
function setField(el, value, apply) {
if (value != null && value !== '') apply(el, value);
else el.remove();
function setField(element, value, apply) {
if (element && value != null && value !== '') apply(element, value);
else element?.remove();
}
// Clear the search box when navigating away from a search view.
const clearSearchInput = () => {
const el = document.getElementById('search-input');
if (el) el.value = '';
};
function clearSearchInput() {
const input = document.getElementById('search-input');
if (input) input.value = '';
}
function filterLabel(filter) {
if (!filter) return '';
if (filter.type === 'author') return `Autor: ${filter.value}`;
if (filter.type === 'tag') return `Štítek: ${tagLabels.get(filter.value) || filter.value}`;
return `Ročník: ${filter.value.replace(/^expa-/i, 'Expa ')}`;
}
// ---------------------------------------------------------------------------
// Pagination nav builder
// Builds hash links that match the router's URL scheme:
// no filter → # (page 1) or #page/N
// author → #author/name (page 1) or #author/name/page/N
// category → #category/name (page 1) or #category/name/page/N
// ---------------------------------------------------------------------------
function pageHash(page, filter) {
if (!filter) return page === 1 ? '#' : `#page/${page}`;
const base = `${filter.type}/${filter.value}`;
const base = `${filter.type}/${encodeURIComponent(filter.value)}`;
return page === 1 ? `#${base}` : `#${base}/page/${page}`;
}
function buildPaginationNav(page, totalPages, filter) {
const nav = document.createElement('nav');
nav.className = 'pagination';
const prev = document.createElement('a');
prev.textContent = '← Previous';
if (page > 1) {
prev.href = pageHash(page - 1, filter);
} else {
prev.classList.add('disabled');
prev.setAttribute('aria-disabled', 'true');
}
nav.setAttribute('aria-label', 'Stránkování');
const previous = document.createElement('a');
previous.textContent = 'Předchozí';
const next = document.createElement('a');
next.textContent = 'Další';
if (page > 1) previous.href = pageHash(page - 1, filter);
else { previous.classList.add('disabled'); previous.setAttribute('aria-disabled', 'true'); }
if (page < totalPages) next.href = pageHash(page + 1, filter);
else { next.classList.add('disabled'); next.setAttribute('aria-disabled', 'true'); }
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = `Page ${page} of ${totalPages}`;
const next = document.createElement('a');
next.textContent = 'Next →';
if (page < totalPages) {
next.href = pageHash(page + 1, filter);
} else {
next.classList.add('disabled');
next.setAttribute('aria-disabled', 'true');
}
nav.appendChild(prev);
nav.appendChild(info);
nav.appendChild(next);
info.textContent = `${page} z ${totalPages}`;
nav.append(previous, info, next);
return nav;
}
// ---------------------------------------------------------------------------
// Card rendering — shared by loadArticleList and loadSearch
// ---------------------------------------------------------------------------
function renderCard(a) {
const el = document.importNode(templates.card, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
setField(f('image'), a.image, (el, v) => {
el.src = `${PICTURES_URL}${v}`;
el.alt = a.title || a.slug;
function renderTags(container, tags) {
if (!container || !tags?.length) { container?.remove(); return; }
tags.forEach(identifier => {
const link = document.createElement('a');
link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`;
link.textContent = tagLabels.get(identifier) || identifier;
container.appendChild(link);
});
f('link').href = `#${a.hash}`;
f('title').textContent = a.title || a.slug;
setField(f('date'), a.date, (el, v) => {
el.dateTime = v;
el.textContent = v;
});
setField(f('description'), a.description, (el, v) => { el.textContent = v; });
setField(f('reading-time'), a.readingTime, (el, v) => {
el.textContent = `${v} min read`;
});
const authorLink = f('author');
authorLink.textContent = a.author;
authorLink.href = `#author/${a.author}`;
const catLink = f('category');
catLink.textContent = a.category;
catLink.href = `#category/${a.category}`;
return el;
}
// ---------------------------------------------------------------------------
// Article list — with optional author or category filter and pagination
// filter: { type: 'author'|'category', value: string } or null for all
// ---------------------------------------------------------------------------
export async function loadArticleList(filter, page = 1) {
function renderFilterLink(label, href, count, active = false) {
const link = document.createElement('a');
link.className = `filter-link${active ? ' is-active' : ''}`;
link.href = href;
if (active) link.setAttribute('aria-current', 'page');
link.textContent = label;
if (count != null) {
const countElement = document.createElement('span');
countElement.className = 'filter-count';
countElement.textContent = `(${count})`;
link.appendChild(countElement);
}
return link;
}
function renderFilterNavigation(articles, activeFilter = null) {
const years = document.getElementById('year-filters');
const tags = document.getElementById('tag-filters');
if (!years || !tags) return;
years.innerHTML = '';
tags.innerHTML = '';
const yearCounts = new Map();
articles.forEach(article => {
const year = Number(article.expa);
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
});
const yearList = document.createElement('div');
yearList.className = 'filter-list';
yearList.appendChild(renderFilterLink('Všechny články', '#', articles.length, !activeFilter));
for (let year = 2026; year >= 2009; year -= 1) {
const count = yearCounts.get(year) || 0;
const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `expa-${year}`;
yearList.appendChild(renderFilterLink(`Expa ${year}`, `#category/expa-${year}`, count, active));
}
years.appendChild(yearList);
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
const tagList = document.createElement('div');
tagList.className = 'filter-list';
[...tagLabels.entries()]
.filter(([id]) => tagCounts.has(id))
.sort((a, b) => (tagLabels.get(a[0]) || a[0]).localeCompare(tagLabels.get(b[0]) || b[0], 'cs'))
.forEach(([id, label]) => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
tagList.appendChild(renderFilterLink(label, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
});
tags.appendChild(tagList);
}
function renderCard(article) {
const fragment = document.importNode(templates.card, true);
const element = fragment.firstElementChild;
if (!element) throw new Error('Šablona článku je prázdná.');
const field = name => element.querySelector(`[data-field="${name}"]`);
const imageLink = field('image-link');
setField(field('image'), article.coverUrl, (image, value) => {
image.src = value;
image.alt = article.title || article.slug;
if (imageLink) {
imageLink.href = `#${article.hash}`;
imageLink.removeAttribute('aria-hidden');
imageLink.removeAttribute('tabindex');
}
});
if (!article.coverUrl) {
imageLink?.remove();
element.classList.add('card--no-image');
}
field('link').href = `#${article.hash}`;
field('title').textContent = article.title || article.slug;
setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); });
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; });
const author = field('author');
author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`;
const category = field('category');
category.textContent = `Expa ${article.expa || article.category.replace(/^expa-/i, '')}`;
category.href = `#category/${encodeURIComponent(article.category)}`;
renderTags(field('tags'), article.tags);
return element;
}
export async function loadArticleList(filter = null, page = 1) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Loading articles…</p>';
clearSearchInput();
let articles;
const view = document.getElementById('list-view');
view.innerHTML = '<p class="loading">Načítám články…</p>';
try {
articles = await discoverArticles();
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
// Apply optional author or category filter (case-insensitive)
if (filter) {
articles = articles.filter(
a => a[filter.type] && a[filter.type].toLowerCase() === filter.value.toLowerCase()
);
}
if (articles.length === 0) {
listView.innerHTML = '<p class="empty">No articles found.</p>';
return;
}
// Pagination — clamp page to valid range
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, totalPages));
const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
listView.innerHTML = '';
// Filter heading
if (filter) {
const h = document.createElement('h2');
h.className = 'category-heading';
h.textContent = `${filter.type === 'author' ? 'Author' : 'Category'}: ${filter.value}`;
listView.appendChild(h);
}
visible.forEach(a => listView.appendChild(renderCard(a)));
if (totalPages > 1) {
listView.appendChild(buildPaginationNav(page, totalPages, filter));
}
}
// ---------------------------------------------------------------------------
// Author profile card renderer
// Known social fields rendered as labelled pill links.
// ---------------------------------------------------------------------------
const SOCIAL_LABELS = {
github: 'GitHub',
twitter: 'Twitter',
linkedin: 'LinkedIn',
mastodon: 'Mastodon',
website: 'Website',
};
function renderAuthorProfile(profile) {
const el = document.importNode(templates.authorProfile, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
setField(f('avatar'), profile.avatar, (el, v) => {
el.src = `${PICTURES_URL}${v}`;
el.alt = profile.title || profile.author;
});
f('name').textContent = profile.title || profile.author;
const bioEl = f('bio');
if (profile.content && profile.content.trim()) {
// marked output is trusted (author-controlled markdown), innerHTML is intentional
bioEl.innerHTML = marked.parse(profile.content);
} else {
bioEl.remove();
}
const socialEl = f('social');
const links = Object.entries(SOCIAL_LABELS)
.filter(([key]) => profile[key])
.map(([key, label]) => {
const a = document.createElement('a');
a.href = profile[key];
a.textContent = label;
a.target = '_blank';
a.rel = 'noopener noreferrer';
return a;
await ensureTags();
const allArticles = await discoverArticles();
renderFilterNavigation(allArticles, filter);
let articles = allArticles;
if (filter) articles = articles.filter(article => {
if (filter.type === 'tag') return article.tags.includes(filter.value);
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
});
if (links.length > 0) {
links.forEach(a => socialEl.appendChild(a));
} else {
socialEl.remove();
view.innerHTML = '';
if (filter) {
const heading = document.createElement('h2');
heading.className = 'category-heading';
heading.textContent = filterLabel(filter);
view.appendChild(heading);
}
if (!articles.length) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'Nebyly nalezeny žádné články.';
view.appendChild(empty);
return;
}
const pages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, pages));
articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).forEach(article => view.appendChild(renderCard(article)));
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
} catch (error) {
view.innerHTML = `<p class="error">Články se nepodařilo načíst: ${error.message}</p>`;
}
return el;
}
// ---------------------------------------------------------------------------
// Author view — profile card (page 1 only) + filtered article list
// ---------------------------------------------------------------------------
const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon', website: 'Web' };
function renderAuthorProfile(profile) {
const element = document.importNode(templates.authorProfile, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
setField(field('avatar'), profile.avatar, (image, value) => { image.src = `${PICTURES_URL}${value}`; image.alt = profile.title || profile.author; });
field('name').textContent = profile.title || profile.author;
const bio = field('bio');
if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove();
const social = field('social');
const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => {
const link = document.createElement('a'); link.href = profile[key]; link.textContent = label; link.target = '_blank'; link.rel = 'noopener noreferrer'; return link;
});
if (links.length) links.forEach(link => social.appendChild(link)); else social.remove();
return element;
}
export async function loadAuthorView(author, page = 1) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Loading…</p>';
const view = document.getElementById('list-view');
view.innerHTML = '<p class="loading">Načítám…</p>';
clearSearchInput();
let articles, profile;
try {
[articles, profile] = await Promise.all([
discoverArticles(),
fetchAuthorProfile(author),
]);
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
articles = articles.filter(
a => a.author.toLowerCase() === author.toLowerCase()
);
listView.innerHTML = '';
// Profile card — page 1 only
if (page === 1 && profile) {
listView.appendChild(renderAuthorProfile(profile));
}
// Author heading
const h = document.createElement('h2');
h.className = 'category-heading';
h.textContent = `Author: ${author}`;
listView.appendChild(h);
if (articles.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'No articles found.';
listView.appendChild(empty);
return;
}
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, totalPages));
const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
visible.forEach(a => listView.appendChild(renderCard(a)));
if (totalPages > 1) {
listView.appendChild(buildPaginationNav(page, totalPages, { type: 'author', value: author }));
}
const profile = await fetchAuthorProfile(author);
await loadArticleList({ type: 'author', value: author }, page);
if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild);
} catch (error) { view.innerHTML = `<p class="error">Články se nepodařilo načíst: ${error.message}</p>`; }
}
export function loadCategoryView(category, page = 1) {
return loadArticleList({ type: 'category', value: category }, page);
}
export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page);
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
// ---------------------------------------------------------------------------
// Search — filters all articles in-memory across title, description, author, category
// ---------------------------------------------------------------------------
export async function loadSearch(query) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Searching…</p>';
// Populate the search box with the active query
const searchInput = document.getElementById('search-input');
if (searchInput) searchInput.value = query;
let articles;
const view = document.getElementById('list-view');
view.innerHTML = '<p class="loading">Hledám…</p>';
const input = document.getElementById('search-input');
if (input) input.value = query;
try {
articles = await discoverArticles();
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
const q = query.toLowerCase();
const results = articles.filter(a =>
(a.title || '').toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q) ||
a.author.toLowerCase().includes(q) ||
a.category.toLowerCase().includes(q)
);
listView.innerHTML = '';
const heading = document.createElement('h2');
heading.className = 'category-heading';
heading.textContent = `Search: "${query}"`;
listView.appendChild(heading);
if (results.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'No articles match your search.';
listView.appendChild(empty);
return;
}
results.forEach(a => listView.appendChild(renderCard(a)));
await ensureTags();
const needle = query.toLocaleLowerCase('cs');
const allArticles = await discoverArticles();
renderFilterNavigation(allArticles);
const articles = allArticles.filter(article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags.map(tag => tagLabels.get(tag) || tag)]
.some(value => String(value || '').toLocaleLowerCase('cs').includes(needle)));
view.innerHTML = `<h2 class="category-heading">Hledání: „${query}“</h2>`;
if (!articles.length) view.insertAdjacentHTML('beforeend', '<p class="empty">Hledání neodpovídá žádný článek.</p>');
else articles.forEach(article => view.appendChild(renderCard(article)));
} catch (error) { view.innerHTML = `<p class="error">Hledání se nepodařilo: ${error.message}</p>`; }
}
// ---------------------------------------------------------------------------
// Table of contents — built from h2/h3 headings in the rendered content.
// Only generated when there are 3 or more headings.
// ---------------------------------------------------------------------------
function buildTOC(contentEl) {
const headings = [...contentEl.querySelectorAll('h2, h3')];
function buildTOC(content) {
const headings = [...content.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
// Assign unique IDs so anchor links work; append -2/-3 on collisions
const seen = new Map();
headings.forEach(h => {
if (!h.id) {
const base = h.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
const count = (seen.get(base) ?? 0) + 1;
seen.set(base, count);
h.id = count === 1 ? base : `${base}-${count}`;
}
headings.forEach(heading => {
const base = heading.textContent.toLocaleLowerCase('cs').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'sekce';
const count = (seen.get(base) || 0) + 1; seen.set(base, count); heading.id = count === 1 ? base : `${base}-${count}`;
});
const nav = document.createElement('nav');
nav.className = 'toc';
const title = document.createElement('p');
title.className = 'toc-title';
title.textContent = 'Contents';
nav.appendChild(title);
const ol = document.createElement('ol');
ol.className = 'toc-list';
headings.forEach(h => {
const li = document.createElement('li');
if (h.tagName === 'H3') li.classList.add('toc-h3');
const a = document.createElement('a');
a.href = `#${h.id}`;
a.textContent = h.textContent;
li.appendChild(a);
ol.appendChild(li);
const nav = document.createElement('nav'); nav.className = 'toc';
nav.innerHTML = '<p class="toc-title">Obsah</p><ol class="toc-list"></ol>';
headings.forEach(heading => {
const item = document.createElement('li'); if (heading.tagName === 'H3') item.classList.add('toc-h3');
const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent; item.appendChild(link); nav.lastChild.appendChild(item);
});
nav.appendChild(ol);
return nav;
}
// ---------------------------------------------------------------------------
// Single article view
// hash is "author/category/slug"; fetch path is "BASE_URL/articles/author/category/slug.md"
// ---------------------------------------------------------------------------
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
const articleView = document.getElementById('article-view');
articleView.innerHTML = '<p class="loading">Loading…</p>';
clearSearchInput();
const [author, category] = hash.split('/');
// Fetch article and author profile in parallel (author is known from hash)
let raw, profile;
const view = document.getElementById('article-view');
view.innerHTML = '<p class="loading">Načítám…</p>';
try {
[raw, profile] = await Promise.all([
fetch(`${BASE_URL}articles/${hash}.md`).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
}),
fetchAuthorProfile(author),
await ensureTags();
const article = await findArticle(hash);
const [raw, profile] = await Promise.all([
fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }),
fetchAuthorProfile(article.author),
]);
} catch (err) {
articleView.innerHTML = `<p class="error">Could not load article: ${err.message}</p>`;
return;
}
const { meta, content } = parseFrontmatter(raw);
// Reading time computed from the raw content
const words = content.trim().split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
document.title = meta.title ? `${meta.title} — cmjs` : 'cmjs';
const el = document.importNode(templates.article, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
f('title').textContent = meta.title || hash;
setField(f('date'), meta.date, (el, v) => {
el.dateTime = v;
el.textContent = v;
});
setField(f('description'), meta.description, (el, v) => { el.textContent = v; });
setField(f('reading-time'), readingTime, (el, v) => {
el.textContent = `${v} min read`;
});
// Hero image is inside <figure> — remove the whole figure when there's no image
const imgEl = f('image');
if (meta.image) {
imgEl.src = `${PICTURES_URL}${meta.image}`;
imgEl.alt = meta.title || hash;
} else {
imgEl.closest('figure')?.remove();
}
const authorLink = f('author');
authorLink.textContent = author;
authorLink.href = `#author/${author}`;
const catLink = f('category');
catLink.textContent = category;
catLink.href = `#category/${category}`;
// Render markdown then build TOC from the resulting headings
const contentEl = f('content');
// marked output is trusted (author-controlled markdown), innerHTML is intentional
contentEl.innerHTML = marked.parse(content);
const toc = buildTOC(contentEl);
if (toc) contentEl.insertBefore(toc, contentEl.firstChild);
// Back link above the article
const backLink = document.createElement('a');
backLink.href = '#';
backLink.className = 'back-link';
backLink.textContent = '← Back to articles';
articleView.innerHTML = '';
articleView.appendChild(backLink);
articleView.appendChild(el);
if (profile) {
const divider = document.createElement('hr');
divider.className = 'article-divider';
articleView.appendChild(divider);
articleView.appendChild(renderAuthorProfile(profile));
}
const { meta, content } = parseFrontmatter(raw);
document.title = `${meta.title || article.slug} | Expediční blogy`;
const element = document.importNode(templates.article, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
field('title').textContent = meta.title || article.slug;
setField(field('date'), meta.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); });
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; });
const image = field('image');
if (article.coverUrl) { image.src = article.coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`;
const category = field('category'); category.textContent = `Expa ${article.expa}`; category.href = `#category/${encodeURIComponent(article.category)}`;
renderTags(field('tags'), article.tags);
const contentElement = field('content');
contentElement.innerHTML = marked.parse(content);
contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); });
const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild);
const back = document.createElement('a'); back.href = '#'; back.className = 'back-link'; back.textContent = 'Zpět na články';
view.innerHTML = ''; view.append(back, element);
if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); }
} catch (error) { view.innerHTML = `<p class="error">Článek se nepodařilo načíst: ${error.message}</p>`; }
}
+1
View File
@@ -9,6 +9,7 @@
<div class="article-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
<span class="tag-list" data-field="tags"></span>
</div>
</header>
<figure class="hero-image">
+4 -1
View File
@@ -1,5 +1,7 @@
<article class="card">
<img class="card-image" data-field="image" alt="">
<a class="card-media" data-field="image-link" href="" aria-hidden="true" tabindex="-1">
<img class="card-image" data-field="image" alt="" loading="lazy">
</a>
<div class="card-body">
<h2><a data-field="link" href=""><span data-field="title"></span></a></h2>
<div class="card-byline">
@@ -10,6 +12,7 @@
<div class="card-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
<span class="tag-list" data-field="tags"></span>
</div>
</div>
</article>
+22 -4
View File
@@ -2,14 +2,14 @@
body > header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0.8rem 1.5rem;
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
#site-title {
font-size: 1.2rem;
font-size: 1.35rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
@@ -25,17 +25,35 @@ body > header {
#search-form {
flex: 1;
max-width: 360px;
display: flex;
gap: 0.5rem;
}
#search-input {
width: 100%;
padding: 0.35rem 0.75rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
#search-form button {
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
padding: 0.55rem 0.9rem;
background: var(--color-accent);
color: #fff;
font: inherit;
cursor: pointer;
}
#search-form button:hover,
#search-form button:focus-visible {
filter: brightness(1.12);
}
#search-input::placeholder {
@@ -55,7 +73,7 @@ body > header {
body > header {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem 1rem;
padding: 0.8rem 1rem;
}
#search-form {
+99 -7
View File
@@ -1,15 +1,107 @@
/* Main content wrapper */
main {
max-width: 720px;
margin: 2rem auto;
padding: 0 1.5rem;
/* Main content wrapper and blog filter rail */
main.blog-shell {
max-width: 1180px;
margin: 3rem auto 5rem;
padding: 0 2rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 270px;
gap: 4rem;
align-items: start;
}
.blog-content { min-width: 0; }
.blog-sidebar {
border: 1px solid var(--color-border);
padding: 1.5rem 1.35rem;
position: sticky;
top: 1.5rem;
background: color-mix(in srgb, var(--color-bg) 92%, white 8%);
}
.filter-section + .filter-section {
border-top: 1px solid var(--color-border);
margin-top: 1.5rem;
padding-top: 1.5rem;
}
.filter-section h2 {
color: var(--color-text-soft);
font-size: 1.05rem;
font-weight: 500;
letter-spacing: 0.08em;
margin: 0 0 1rem;
}
.filter-list {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.filter-link {
display: block;
padding: 0.35rem 0.5rem;
color: var(--color-text-muted);
text-decoration: none;
border-left: 2px solid transparent;
}
.filter-link:hover,
.filter-link:focus-visible,
.filter-link.is-active {
color: var(--color-accent);
border-left-color: var(--color-accent);
}
.filter-count {
color: var(--color-text-muted);
font-size: 0.8em;
margin-left: 0.3rem;
}
@media (max-width: 900px) {
main.blog-shell {
margin-top: 2rem;
grid-template-columns: 1fr;
gap: 2rem;
}
.blog-sidebar {
position: static;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
.filter-section + .filter-section {
border-top: 0;
border-left: 1px solid var(--color-border);
margin-top: 0;
padding-top: 0;
padding-left: 1.5rem;
}
}
@media (max-width: 640px) {
main {
margin: 1rem auto;
main.blog-shell {
margin: 1.5rem auto 3rem;
padding: 0 1rem;
}
.blog-sidebar {
display: block;
}
.filter-section + .filter-section {
border-left: 0;
border-top: 1px solid var(--color-border);
margin-top: 1.5rem;
padding-top: 1.5rem;
padding-left: 0;
}
}
/* State messages (loading / error / empty) */
+17
View File
@@ -50,6 +50,23 @@ body {
padding: 0;
}
/* The expedition blog follows the same restrained night-sky palette as the
main site, independently of the visitor's operating-system theme. */
body.blog-page {
--color-bg: #050506;
--color-surface: #101114;
--color-border: #292b31;
--color-text: #f4f1ed;
--color-text-soft: #c6c4c2;
--color-text-muted: #92959d;
--color-accent: #e25555;
--color-tag-bg: #191b21;
--color-tag-border: #363941;
--color-code-bg: #131519;
--color-error: #ff7777;
--shadow-card: 0 12px 35px rgba(0, 0, 0, 0.28);
}
/* Global keyboard-navigation focus indicator */
:focus-visible {
outline: 2px solid var(--color-accent);
+25 -11
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cmjs</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1">
<title>Expediční blogy</title>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
@@ -14,23 +14,37 @@
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="cmjs/css/components/author-profile.css">
</head>
<body>
<body class="blog-page">
<header>
<!-- Site title always links back to the article list -->
<a href="#" id="site-title">cmjs</a>
<a href="#" id="site-title">Expediční blogy</a>
<!-- Search form — submits to #search/query via router.js -->
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
<input type="search" id="search-input" placeholder="Hledat v článcích" autocomplete="off" aria-label="Hledat v článcích">
<button type="submit">Hledat</button>
</form>
</header>
<main>
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
<main class="blog-shell">
<section class="blog-content" aria-label="Články">
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
<!-- Article view: shown when a slug hash is present -->
<div id="article-view" hidden></div>
<!-- Article view: shown when a slug hash is present -->
<div id="article-view" hidden></div>
</section>
<aside class="blog-sidebar" aria-label="Filtrování blogu">
<section class="filter-section">
<h2>Ročníky</h2>
<nav id="year-filters" aria-label="Filtrovat podle ročníku"></nav>
</section>
<section class="filter-section">
<h2>Rubriky</h2>
<nav id="tag-filters" aria-label="Filtrovat podle rubriky"></nav>
</section>
</aside>
</main>
<script type="module" src="cmjs/js/router.js"></script>