diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e290289 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/cmjs/articles/ +/cmjs/pictures/ \ No newline at end of file diff --git a/README.md b/README.md index 65d446e..f9825d2 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/cmjs/articles/alice/alice.md b/cmjs/articles/alice/alice.md deleted file mode 100644 index ae9037d..0000000 --- a/cmjs/articles/alice/alice.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/alice/linux/getting-started-with-linux.md b/cmjs/articles/alice/linux/getting-started-with-linux.md deleted file mode 100644 index 034aa38..0000000 --- a/cmjs/articles/alice/linux/getting-started-with-linux.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/alice/linux/understanding-permissions.md b/cmjs/articles/alice/linux/understanding-permissions.md deleted file mode 100644 index 67d33e2..0000000 --- a/cmjs/articles/alice/linux/understanding-permissions.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/alice/programming/python-for-beginners.md b/cmjs/articles/alice/programming/python-for-beginners.md deleted file mode 100644 index c451eb7..0000000 --- a/cmjs/articles/alice/programming/python-for-beginners.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/bob/bob.md b/cmjs/articles/bob/bob.md deleted file mode 100644 index 8022003..0000000 --- a/cmjs/articles/bob/bob.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/bob/devops/introduction-to-docker.md b/cmjs/articles/bob/devops/introduction-to-docker.md deleted file mode 100644 index a66e843..0000000 --- a/cmjs/articles/bob/devops/introduction-to-docker.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/bob/devops/nginx-reverse-proxy.md b/cmjs/articles/bob/devops/nginx-reverse-proxy.md deleted file mode 100644 index 9add0f6..0000000 --- a/cmjs/articles/bob/devops/nginx-reverse-proxy.md +++ /dev/null @@ -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. diff --git a/cmjs/articles/bob/linux/bash-scripting-basics.md b/cmjs/articles/bob/linux/bash-scripting-basics.md deleted file mode 100644 index 7fc6968..0000000 --- a/cmjs/articles/bob/linux/bash-scripting-basics.md +++ /dev/null @@ -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. diff --git a/cmjs/blog-config/.gitkeep b/cmjs/blog-config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cmjs/blog-config/.gitkeep @@ -0,0 +1 @@ + diff --git a/cmjs/blog-config/tags.json b/cmjs/blog-config/tags.json new file mode 100644 index 0000000..957acdd --- /dev/null +++ b/cmjs/blog-config/tags.json @@ -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 + } + ] +} diff --git a/cmjs/css/components/article.css b/cmjs/css/components/article.css index bdf2b92..4128d07 100644 --- a/cmjs/css/components/article.css +++ b/cmjs/css/components/article.css @@ -68,6 +68,7 @@ .article-meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.5rem; font-size: 0.85rem; } diff --git a/cmjs/css/components/card.css b/cmjs/css/components/card.css index ffd4208..9b7ec78 100644 --- a/cmjs/css/components/card.css +++ b/cmjs/css/components/card.css @@ -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; } } diff --git a/cmjs/export-report.txt b/cmjs/export-report.txt new file mode 100644 index 0000000..2763c51 --- /dev/null +++ b/cmjs/export-report.txt @@ -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 diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index d72afa4..408ad2a 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -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 = /]*\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; } } diff --git a/cmjs/js/frontmatter.js b/cmjs/js/frontmatter.js index 123873e..cf63992 100644 --- a/cmjs/js/frontmatter.js +++ b/cmjs/js/frontmatter.js @@ -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] }; } diff --git a/cmjs/js/router.js b/cmjs/js/router.js index fdb55c3..787b639 100644 --- a/cmjs/js/router.js +++ b/cmjs/js/router.js @@ -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 = `

Failed to initialise: ${err.message}

`; + document.body.textContent = `Blog se nepodařilo spustit: ${err.message}`; }); ready.then(router).catch(() => {}); diff --git a/cmjs/js/views.js b/cmjs/js/views.js index a366681..5f6c590 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -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 = '

Loading articles…

'; - clearSearchInput(); - - let articles; + const view = document.getElementById('list-view'); + view.innerHTML = '

Načítám články…

'; try { - articles = await discoverArticles(); - } catch (err) { - listView.innerHTML = `

Could not load articles: ${err.message}

`; - 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 = '

No articles found.

'; - 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 = `

Články se nepodařilo načíst: ${error.message}

`; } - - 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 = '

Loading…

'; - + const view = document.getElementById('list-view'); + view.innerHTML = '

Načítám…

'; clearSearchInput(); - - let articles, profile; try { - [articles, profile] = await Promise.all([ - discoverArticles(), - fetchAuthorProfile(author), - ]); - } catch (err) { - listView.innerHTML = `

Could not load articles: ${err.message}

`; - 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 = `

Články se nepodařilo načíst: ${error.message}

`; } } -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 = '

Searching…

'; - - // 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 = '

Hledám…

'; + const input = document.getElementById('search-input'); + if (input) input.value = query; try { - articles = await discoverArticles(); - } catch (err) { - listView.innerHTML = `

Could not load articles: ${err.message}

`; - 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 = `

Hledání: „${query}“

`; + if (!articles.length) view.insertAdjacentHTML('beforeend', '

Hledání neodpovídá žádný článek.

'); + else articles.forEach(article => view.appendChild(renderCard(article))); + } catch (error) { view.innerHTML = `

Hledání se nepodařilo: ${error.message}

`; } } -// --------------------------------------------------------------------------- -// 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 = '

Obsah

    '; + 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 = '

    Loading…

    '; - 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 = '

    Načítám…

    '; 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 = `

    Could not load article: ${err.message}

    `; - 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
    — 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 = `

    Článek se nepodařilo načíst: ${error.message}

    `; } } diff --git a/cmjs/templates/article.html b/cmjs/templates/article.html index 192f4d3..8d3f308 100644 --- a/cmjs/templates/article.html +++ b/cmjs/templates/article.html @@ -9,6 +9,7 @@
    diff --git a/cmjs/templates/card.html b/cmjs/templates/card.html index 230eac7..57b88f6 100644 --- a/cmjs/templates/card.html +++ b/cmjs/templates/card.html @@ -1,5 +1,7 @@
    - +

    diff --git a/css/header.css b/css/header.css index 2d5466d..0ff4c5d 100644 --- a/css/header.css +++ b/css/header.css @@ -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 { diff --git a/css/layout.css b/css/layout.css index 97ceab5..ba0bef0 100644 --- a/css/layout.css +++ b/css/layout.css @@ -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) */ diff --git a/css/reset.css b/css/reset.css index ab3e102..f559178 100644 --- a/css/reset.css +++ b/css/reset.css @@ -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); diff --git a/index.html b/index.html index 620585a..33cde5a 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,9 @@ - + - - cmjs + + Expediční blogy @@ -14,23 +14,37 @@ - +
    - cmjs + Expediční blogy
    -
    - -
    +
    +
    + +
    - - + + +
    + +