prepare for blog posts
This commit is contained in:
25 files changed
+917
-1039
No files matched your search
@@ -0,0 +1,2 @@
|
||||
/cmjs/articles/
|
||||
/cmjs/pictures/
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
.article-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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(/&/g, '&').replace(/"/g, '"').replace(/'/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
@@ -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
@@ -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
@@ -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>`; }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user