hotovy projekt

This commit is contained in:
Marunič 2026-03-22 12:58:49 +01:00
parent 68341ad657
commit 6cdbf74786
16 changed files with 1928 additions and 0 deletions

156
zadanie 1/README.md Executable file
View File

@ -0,0 +1,156 @@
# Blog Dokumentacia
Osobný blog nasadený pomocou Docker Compose. Sklada sa zo styroch sluzieb beziacich v samostatnych kontajneroch.
# Popis aplikacie
Webová aplikacia umoznuje:
- prezerať zoznam blogových príspevkov
- citat jednotlive prispevky
- vytvarat a upravovat nove príspevky
- spravovat databazu cez pgAdmin webove rozhranie
# Podmienky nasadenia
Softver | Minimálna verzia
Linux (Ubuntu/Debian/Fedora)
Docker Engine | 24.0
Docker Compose (plugin) | 2.20
Overenie instalacie:
```bash
docker --version
docker compose version
```
# Architektura prehlad sluzieb
```
Priehliadac -> Nginx(proxy - port 80) -> Backend(Node.js) -> DB(Postgres) <- pgAdmin
| ^
| |
------------------------------------------------------------
port 8080
# Zoznam kontajnerov
| Kontajner | Obraz | Port | Popis |
| `blog-nginx` | `nginx:alpine` | `80` | Webserver pre frontend + reverse proxy na backend |
|`blog-backend` | vlastny build | interny `3000` | Node.js REST API, komunikuje s DB |
| `blog-db` | `postgres:16-alpine`| interny `5432` | PostgreSQL databaza, uchovava prispevky |
| `blog-pgadmin` | `dpage/pgadmin4` | `8080` | Webove rozhranie pre spravu databazy |
## Virtuálne siete
# `blog-network`
- Interna Docker siet, do ktorej su zapojene vsetky styri kontajnery
- Kontajnery sa navzajom adresuju cez db
- Z hostovského pocitaca su zvonka dostupne len služby s mapovanym portom (`nginx` na 80, `pgadmin` na 8080)
## Trvale zvazky
# `db-data`
- Typ: pomenovay zvazok (managed volume)
- Pripojeny na: `/var/lib/postgresql/data` v kontajneri `blog-db`
- ucel: data PostgreSQL databazy (prispevky blogu) preziju restart alebo zmazanie kontajnera
- Zvazok sa zachova pri `stop-app.sh`, zmaze sa len pri `remove-app.sh`
# Konfigurácia kontajnerov
## Nginx
- Staticke subory frontendu su namontovane z `./frontend` do `/usr/share/nginx/html` (read-only)
- Vlastna konfiguracia z `nginx.conf` nahradza predvolenu
- Vsetky requesty na `/api/*` presmeruje na `http://backend:3000` (reverse proxy, v praxi to zlepsuje bezpecnost
navstevnika stranky resp. uzivatela)
# Backend (Node.js)
- Zostavany z `./backend/Dockerfile` na zaklade obrazu `node:20-alpine`
- Konfiguracia databazy sa predava cez premenne prostredia (`DB_HOST`, `DB_USER`, atd.)
- Startuje az po tom, co DB kontajner hlasi `healthy` (healthcheck)
# PostgreSQL
- pri prvom starte automaticky spusti `./backend/init.sql` vytvori tabulku a vlozi ukazkovy prispevok
- data uklada do zvazku `db-data`
# pgAdmin
- oredvolene prihlasenie: `admin@blog.local` / `admin`
- po prihlaseni je potrebne manualne pridat server s udajmi: host `db`, port `5432`, db `blog`,
user `blog_user`, heslo `blog_pass`
## Navod na manazment aplikacie
# Priprava (jednorazovo)
```
./prepare-app.sh
```
# Spustenie
```
./start-app.sh
```
# Zastavenie (ulozi sa stav)
```
./stop-app.sh
```
# Opatovne spustenie
```
./start-app.sh
```
# Uplne odstranenie
```
./remove-app.sh
```
# Pristup cez prehliadac
| URL | Popis |
|---|---|
| `http://localhost` | Blog zoznam prispevkov |
| `http://localhost/new-post.html` | Formular na novy prispevok |
| `http://localhost:8080` | pgAdmin sprava databazy |
## Pouzite zdroje
- [Docker dokumentácia](https://docs.docker.com/)
- [Docker Compose dokumentácia](https://docs.docker.com/compose/)
- [Express.js dokumentácia](https://expressjs.com/)
- [node-postgres (pg) dokumentácia](https://node-postgres.com/)
- [Nginx dokumentácia](https://nginx.org/en/docs/)
- [PostgreSQL dokumentácia](https://www.postgresql.org/docs/)
## Použitie umelej inteligencie
Pri tvorbe projektu bol použitý AI asistent Claude (Anthropic, claude.ai).
Použitie:
- Navrh architektúry aplikácie a výber technológií
- Generovanie kostry kodu (`server.js`, `db.js`, HTML stránky)
- Úprava Docker konfigurácie (`docker-compose.yaml`, `nginx.conf`, `Dockerfile`)
- Generovanie grafickej úpravy tejto dokumentácie
- interne CSS v index.html
Všetok vygenerovaný kód bol preštudovaný, pochopený a upravovaný. Komentáre v kóde odrážajú
vlastné porozumenie danej problematiky.

24
zadanie 1/backend/Dockerfile Executable file
View File

@ -0,0 +1,24 @@
# =============================================================
# Dockerfile Inštrukcie pre zostavenie Docker obrazu backendu
# =============================================================
# Začneme od oficiálneho Node.js obrazu (verzia LTS = dlhodobá podpora)
FROM node:20-alpine
# Nastavíme pracovný adresár vnútri kontajnera
WORKDIR /app
# Skopírujeme package.json a nainštalujeme závislosti
# Robíme to PRED kopírovaním zvyšku kódu Docker cachuje vrstvy
# Ak sa zmení len server.js, npm install sa znova nespustí
COPY package.json .
RUN npm install --omit=dev
# Skopírujeme zvyšok kódu do kontajnera
COPY . .
# Informujeme Docker, že kontajner počúva na tomto porte
EXPOSE 3000
# Príkaz, ktorý sa spustí keď kontajner naštartuje
CMD ["node", "server.js"]

37
zadanie 1/backend/db.js Executable file
View File

@ -0,0 +1,37 @@
// =============================================================
// db.js Pripojenie k PostgreSQL databáze
// =============================================================
// Tento modul exportuje funkciu query(), ktorú voláme v server.js
// Namiesto priameho pripájania pri každom requeste používame
// "connection pool" znovupoužiteľné pripojenia (efektívnejšie)
// =============================================================
const { Pool } = require('pg');
// Pool skupina otvorených pripojení k databáze
// Keď príde request, vezme voľné pripojenie z poolu a vráti ho späť
// Konfigurácia sa číta z premenných prostredia (environment variables)
// Tie nastavíme v Docker Compose takto kód nevie o konkrétnom hesle
const pool = new Pool({
host: process.env.DB_HOST || 'localhost', // adresa DB servera
port: parseInt(process.env.DB_PORT) || 5432, // štandardný PostgreSQL port
database: process.env.DB_NAME || 'blog', // názov databázy
user: process.env.DB_USER || 'blog_user', // používateľ DB
password: process.env.DB_PASSWORD || 'blog_pass', // heslo
});
// Otestujeme pripojenie hneď pri štarte servera
pool.connect((err, client, release) => {
if (err) {
console.error('Chyba pri pripájaní k databáze:', err.message);
} else {
console.log('Úspešne pripojený k PostgreSQL databáze');
release(); // vrátime pripojenie späť do poolu
}
});
// Exportujeme pomocnú funkciu query()
// Ostatné súbory ju volajú ako: db.query('SELECT ...', [param1, ...])
module.exports = {
query: (text, params) => pool.query(text, params),
};

28
zadanie 1/backend/init.sql Executable file
View File

@ -0,0 +1,28 @@
-- =============================================================
-- init.sql Inicializácia databázy
-- =============================================================
-- Tento skript sa spustí automaticky pri prvom štarte PostgreSQL
-- kontajnera (Docker ho hľadá v /docker-entrypoint-initdb.d/)
-- =============================================================
-- Vytvoríme tabuľku pre príspevky
-- IF NOT EXISTS skript môžeme spustiť aj viackrát bez chyby
CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
category VARCHAR(100),
excerpt VARCHAR(300),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP
);
-- Vložíme ukážkový príspevok, aby blog nebol prázdny pri prvom spustení
INSERT INTO posts (title, content, category, excerpt) VALUES
(
'Vitajte na mojom blogu',
'Toto je môj prvý príspevok. Blog beží na Docker infraštruktúre skladajúcej sa z Nginx webservera, Node.js backendu a PostgreSQL databázy.
Každá služba beží vo vlastnom kontajneri a komunikujú medzi sebou cez virtuálnu Docker sieť.',
'Technológie',
'Prvý príspevok predstavenie blogu a jeho technickej infraštruktúry.'
);

14
zadanie 1/backend/package.json Executable file
View File

@ -0,0 +1,14 @@
{
"name": "blog-backend",
"version": "1.0.0",
"description": "Backend API pre osobný blog",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"pg": "^8.11.3"
}
}

181
zadanie 1/backend/server.js Executable file
View File

@ -0,0 +1,181 @@
// server.js Blog API server
// Používame Express.js minimálny webový framework pre Node.js
// Počúva na porte 3000 a odpovedá na HTTP požiadavky od frontendu
const express = require('express');
const cors = require('cors');
const db = require('./db');
const app = express();
const PORT = process.env.PORT || 3000;
// MIDDLEWARE
// Middleware sú funkcie, ktoré spracujú každý request
// predtým, než sa dostane k nášmu kódu (route handleru)
// Povolíme príjem JSON v tele requestu (napr. pri POST)
app.use(express.json());
// CORS Cross-Origin Resource Sharing
// Povolíme frontendu (Nginx na porte 80) volať náš backend (port 3000)
// Bez toho by prehliadač zablokoval požiadavky z inej domény/portu
app.use(cors());
// ROUTES definujeme čo sa stane pri každej URL
// GET /health jednoduchý endpoint na overenie, že server beží
// Používa sa napr. v Dockeri ako "healthcheck"
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// GET /api/posts vráti zoznam všetkých príspevkov
// Príspevky sú zoradené od najnovšieho po najstarší (ORDER BY created_at DESC)
app.get('/api/posts', async (req, res) => {
try {
const result = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC'
);
// result.rows je pole objektov každý riadok z DB je jeden objekt
res.json(result.rows);
} catch (err) {
console.error('Chyba pri načítaní príspevkov:', err.message);
res.status(500).json({ error: 'Interná chyba servera' });
}
});
// GET /api/posts/:id vráti jeden konkrétny príspevok podľa ID
// :id je parameter v URL napr. /api/posts/5 → id = 5
app.get('/api/posts/:id', async (req, res) => {
const id = parseInt(req.params.id); // načítame ID z URL
// Základná validácia ID musí byť číslo
if (isNaN(id)) {
return res.status(400).json({ error: 'ID musí byť číslo' });
}
try {
// $1 je placeholder ochrana pred SQL injection útokom
// Nikdy nevkladáme premennú priamo do SQL reťazca!
const result = await db.query(
'SELECT * FROM posts WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Príspevok nenájdený' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('Chyba pri načítaní príspevku:', err.message);
res.status(500).json({ error: 'Interná chyba servera' });
}
});
// POST /api/posts vytvorí nový príspevok
// Dáta prídu v tele requestu ako JSON
app.post('/api/posts', async (req, res) => {
// Destructuring vyberieme len polia, ktoré potrebujeme
const { title, content, category, excerpt } = req.body;
// Validácia vstupných dát
if (!title || !content) {
return res.status(400).json({ error: 'Nadpis a obsah sú povinné' });
}
try {
// RETURNING * PostgreSQL vráti vložený riadok aj s vygenerovaným ID a dátumom
const result = await db.query(
`INSERT INTO posts (title, content, category, excerpt)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[title.trim(), content.trim(), category || null, excerpt?.trim() || null]
);
// HTTP 201 Created signalizuje, že bol vytvorený nový zdroj
res.status(201).json(result.rows[0]);
} catch (err) {
console.error('Chyba pri vytváraní príspevku:', err.message);
res.status(500).json({ error: 'Interná chyba servera' });
}
});
app.put('/api/posts/:id', async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ error: 'ID musí byť číslo' });
}
const { title, content, category, excerpt } = req.body;
if (!title || !content) {
return res.status(400).json({ error: 'Nadpis a obsah sú povinné' });
}
try {
const result = await db.query(
`UPDATE posts
SET title = $1, content = $2, category = $3, excerpt = $4, updated_at = NOW()
WHERE id = $5
RETURNING *`,
[title.trim(), content.trim(), category || null, excerpt?.trim() || null, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Príspevok nenájdený' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('Chyba pri úprave príspevku:', err.message);
res.status(500).json({ error: 'Interná chyba servera' });
}
});
// DELETE /api/posts/:id zmaže príspevok podľa ID
app.delete('/api/posts/:id', async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ error: 'ID musí byť číslo' });
}
try {
const result = await db.query(
'DELETE FROM posts WHERE id = $1 RETURNING id',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Príspevok nenájdený' });
}
// HTTP 200 s potvrdením zmazania
res.json({ message: 'Príspevok bol zmazaný', id });
} catch (err) {
console.error('Chyba pri mazaní príspevku:', err.message);
res.status(500).json({ error: 'Interná chyba servera' });
}
});
// SPUSTENIE SERVERA
// app.listen() spustí server a začne počúvať na danom porte
// '0.0.0.0' znamená: počúvaj na všetkých sieťových rozhraniach
// (potrebné v Dockeri, inak by počúval len na localhost)
app.listen(PORT, '0.0.0.0', () => {
console.log(`Backend server beží na porte ${PORT}`);
});

130
zadanie 1/docker-compose.yaml Executable file
View File

@ -0,0 +1,130 @@
# =============================================================
# docker-compose.yaml Konfigurácia všetkých služieb
# =============================================================
# Docker Compose číta tento súbor a spustí všetky kontajnery
# naraz, so správnym prepojením a konfiguráciou.
#
# SLUŽBY:
# db PostgreSQL databáza (port 5432)
# backend Node.js API server (port 3000, interný)
# nginx Webserver + proxy (port 80, verejný)
# pgadmin Webové rozhranie DB (port 8080)
#
# SIEŤ: blog-network (interná Docker sieť)
# ZVÄZOK: db-data (trvalé uloženie dát databázy)
# =============================================================
services:
# ── 1. DATABÁZA ─────────────────────────────────────────────
db:
image: postgres:16-alpine # oficiálny obraz, alpine = menší
container_name: blog-db
restart: unless-stopped # reštartuj po páde, nie po ručnom zastavení
environment:
POSTGRES_DB: blog # názov databázy ktorá sa vytvorí
POSTGRES_USER: blog_user # používateľ
POSTGRES_PASSWORD: blog_pass # heslo
ports:
- "5432:5432"
volumes:
# Trvalý zväzok dáta v DB prežijú reštart kontajnera
- db-data:/var/lib/postgresql/data
# Init skript PostgreSQL ho spustí pri prvom štarte
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- blog-network
# Healthcheck Docker kontroluje či je DB pripravená
# Backend nesmie štartovať skôr, než DB prijíma spojenia
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blog_user -d blog"]
interval: 5s # kontroluj každých 5 sekúnd
timeout: 5s # čakaj max 5 sekúnd na odpoveď
retries: 5 # po 5 neúspechoch = unhealthy
# ── 2. BACKEND ──────────────────────────────────────────────
backend:
build: ./backend # zostav obraz z ./backend/Dockerfile
container_name: blog-backend
restart: unless-stopped
environment:
# Tieto premenné číta db.js takto heslo nie je v kóde
DB_HOST: db # názov DB služby = hostname v Docker sieti
DB_PORT: 5432
DB_NAME: blog
DB_USER: blog_user
DB_PASSWORD: blog_pass
PORT: 3000
networks:
- blog-network
# Čakaj kým DB je zdravá až potom spusti backend
depends_on:
db:
condition: service_healthy
# ── 3. NGINX (webserver + reverse proxy) ────────────────────
nginx:
image: nginx:alpine
container_name: blog-nginx
restart: unless-stopped
ports:
# Mapovanie portov: HOST:KONTAJNER
# Prehliadač sa pripojí na localhost:80
- "80:80"
volumes:
# Statické súbory frontendu
- ./frontend:/usr/share/nginx/html:ro # :ro = read-only
# Naša konfigurácia namiesto predvolenej
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- blog-network
depends_on:
- backend
# ── 4. PGADMIN (webové rozhranie pre databázu) ───────────────
pgadmin:
image: dpage/pgadmin4:latest
container_name: blog-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@blog.local
PGADMIN_DEFAULT_PASSWORD: admin
ports:
# pgAdmin dostupný na localhost:8080
- "8080:80"
networks:
- blog-network
depends_on:
- db
# =============================================================
# SIETE
# =============================================================
# Interná virtuálna sieť kontajnery sa navzájom vidia
# cez názov služby (napr. "db", "backend")
# Zvonka (z hosťovského počítača) sú viditeľné len cez ports:
networks:
blog-network:
driver: bridge
# =============================================================
# ZVÄZKY
# =============================================================
# Pomenovaný zväzok Docker ho spravuje samostatne
# Dáta prežijú aj keď sa kontajner zmaže a znova vytvorí
volumes:
db-data:

494
zadanie 1/frontend/index.html Executable file
View File

@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Môj Blog</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #faf8f3;
--ink: #1c1917;
--ink-muted:#78716c;
--accent: #b45309;
--accent-lt:#fef3c7;
--rule: #e7e5e0;
--card-bg: #ffffff;
--mono: 'JetBrains Mono', monospace;
--serif: 'Lora', Georgia, serif;
--display: 'Playfair Display', Georgia, serif;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--serif);
font-size: 1.0625rem;
line-height: 1.75;
min-height: 100vh;
}
/* ── HEADER ───────────────────────────────────── */
header {
border-bottom: 1px solid var(--rule);
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0;
border-bottom: 1px solid var(--rule);
}
.site-tag {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-muted);
}
nav a {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-muted);
text-decoration: none;
margin-left: 2rem;
transition: color 0.2s;
}
nav a:hover { color: var(--accent); }
.header-hero {
padding: 3.5rem 0 3rem;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
}
.site-title {
font-family: var(--display);
font-size: clamp(2.8rem, 6vw, 5rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.02em;
text-align: center;
}
.site-title em {
font-style: italic;
color: var(--accent);
}
.logo_kps {
height: 60px;
width: auto;
object-fit: contain;
align-self: flex-end;
}
/* ── LAYOUT ───────────────────────────────────── */
.container {
max-width: 72rem;
margin: 0 auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
/* ── FEATURED POST ────────────────────────────── */
.featured-section {
padding: 3rem 0 0;
}
.section-label {
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--rule);
}
.featured-card {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
padding: 2.5rem;
background: var(--card-bg);
border: 1px solid var(--rule);
border-left: 4px solid var(--accent);
cursor: pointer;
transition: box-shadow 0.25s;
text-decoration: none;
color: inherit;
}
.featured-card:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
}
.featured-meta {
display: flex;
flex-direction: column;
gap: 1rem;
}
.post-category {
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-lt);
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 2px;
width: fit-content;
}
.featured-title {
font-family: var(--display);
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
}
.featured-excerpt {
color: var(--ink-muted);
font-style: italic;
font-size: 0.95rem;
}
.post-date {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--ink-muted);
letter-spacing: 0.05em;
margin-top: auto;
}
.read-more {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
font-weight: 500;
}
.read-more::after { content: '→'; }
/* ── POST GRID ────────────────────────────────── */
.posts-section { padding: 3rem 0 5rem; }
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
}
.post-card {
background: var(--card-bg);
padding: 2rem;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.post-card:hover { background: var(--accent-lt); }
.post-title {
font-family: var(--display);
font-size: 1.25rem;
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.01em;
}
.post-excerpt {
color: var(--ink-muted);
font-size: 0.9rem;
line-height: 1.65;
flex: 1;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid var(--rule);
}
/* ── EMPTY STATE ──────────────────────────────── */
.empty-state {
padding: 4rem 2rem;
text-align: center;
grid-column: 1 / -1;
}
.empty-state h3 {
font-family: var(--display);
font-size: 1.5rem;
font-style: italic;
color: var(--ink-muted);
margin-bottom: 0.5rem;
}
.empty-state p {
font-size: 0.9rem;
color: var(--ink-muted);
}
/* ── SKELETON LOADING ─────────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--rule) 25%, #ede9e2 50%, var(--rule) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 3px;
}
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
.skeleton-card {
background: var(--card-bg);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sk-tag { height: 1.1rem; width: 5rem; }
.sk-title { height: 1.3rem; width: 80%; }
.sk-title2 { height: 1.3rem; width: 60%; }
.sk-line { height: 0.9rem; width: 100%; }
.sk-line2 { height: 0.9rem; width: 75%; }
/* ── FOOTER ───────────────────────────────────── */
footer {
border-top: 1px solid var(--rule);
padding: 2rem clamp(1.5rem, 5vw, 4rem);
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-copy {
font-family: var(--mono);
font-size: 0.68rem;
color: var(--ink-muted);
letter-spacing: 0.08em;
}
.footer-link {
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
text-decoration: none;
}
.footer-link:hover { text-decoration: underline; }
/* ── TOAST ────────────────────────────────────── */
#toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--ink);
color: var(--bg);
font-family: var(--mono);
font-size: 0.75rem;
padding: 0.75rem 1.25rem;
border-radius: 4px;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
#toast.show { opacity: 1; transform: translateY(0); }
/* ── RESPONSIVE ───────────────────────────────── */
@media (max-width: 700px) {
.header-hero { grid-template-columns: 1fr; }
.site-desc { text-align: left; max-width: 100%; }
.featured-card { grid-template-columns: 1fr; gap: 1.5rem; }
}
/* ── ENTRANCE ANIMATION ───────────────────────── */
.fade-in {
opacity: 0;
transform: translateY(1rem);
animation: fadeUp 0.5s ease forwards;
}
@keyframes fadeUp { to { opacity: 1; transform: none; } }
.delay-1 { animation-delay: 0.1s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.3s; }
</style>
</head>
<body>
<header>
<div class="header-top">
<span class="site-tag">osobný blog</span>
<nav>
<a href="index.html">Domov</a>
<a href="new-post.html">Nový príspevok</a>
</nav>
</div>
<div class="header-hero">
<h1 class="site-title fade-in">Môj<br /><em>Blog</em></h1>
<img
src="logo_kps.png"
alt="Katedra počítačových sietí"
class="logo_kps fade-in delay-1"
/>
</div>
</header>
<main>
<div class="container">
<!-- Featured post -->
<section class="featured-section fade-in delay-2" id="featured-section">
<p class="section-label">Najnovší príspevok</p>
<div id="featured-post"></div>
</section>
<!-- All posts -->
<section class="posts-section fade-in delay-3">
<p class="section-label">Všetky príspevky</p>
<div class="posts-grid" id="posts-grid"></div>
</section>
</div>
</main>
<footer>
<span class="footer-copy">&copy; <span id="year"></span> — Môj Blog</span>
<a href="new-post.html" class="footer-link">+ Napísať článok</a>
</footer>
<div id="toast"></div>
<script>
const API = '/api';
document.getElementById('year').textContent = new Date().getFullYear();
/* ── helpers ── */
function formatDate(iso) {
return new Date(iso).toLocaleDateString('sk-SK', {
day: 'numeric', month: 'long', year: 'numeric'
});
}
function showToast(msg, duration = 3000) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), duration);
}
/* ── skeleton while loading ── */
function renderSkeletons(count) {
return Array.from({ length: count }, () => `
<div class="skeleton-card">
<div class="skeleton sk-tag"></div>
<div class="skeleton sk-title"></div>
<div class="skeleton sk-title2"></div>
<div class="skeleton sk-line"></div>
<div class="skeleton sk-line2"></div>
</div>`).join('');
}
/* ── render featured ── */
function renderFeatured(post) {
if (!post) {
document.getElementById('featured-section').style.display = 'none';
return;
}
document.getElementById('featured-post').innerHTML = `
<a class="featured-card" href="post.html?id=${post.id}">
<div class="featured-meta">
<span class="post-category">${post.category || 'Bez kategórie'}</span>
<h2 class="featured-title">${post.title}</h2>
<p class="featured-excerpt">${post.excerpt || post.content.substring(0, 180) + '…'}</p>
<span class="post-date">${formatDate(post.created_at)}</span>
</div>
<div style="display:flex;align-items:flex-end;justify-content:flex-end;">
<span class="read-more">Čítať článok</span>
</div>
</a>`;
}
/* ── render grid ── */
function renderGrid(posts) {
const grid = document.getElementById('posts-grid');
if (!posts.length) {
grid.innerHTML = `
<div class="empty-state">
<h3>Zatiaľ žiadne príspevky</h3>
<p>Buďte prvý — <a href="new-post.html" style="color:var(--accent)">napíšte článok</a>.</p>
</div>`;
return;
}
grid.innerHTML = posts.map(post => `
<a class="post-card" href="post.html?id=${post.id}">
<span class="post-category">${post.category || 'Bez kategórie'}</span>
<h3 class="post-title">${post.title}</h3>
<p class="post-excerpt">${post.excerpt || post.content.substring(0, 140) + '…'}</p>
<div class="post-footer">
<span class="post-date">${formatDate(post.created_at)}</span>
<span class="read-more">Čítať</span>
</div>
</a>`).join('');
}
/* ── fetch posts ── */
async function loadPosts() {
const grid = document.getElementById('posts-grid');
grid.innerHTML = renderSkeletons(3);
try {
const res = await fetch(`${API}/posts`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const posts = await res.json();
renderFeatured(posts[0] || null);
renderGrid(posts.slice(1)); // rest in grid (featured is posts[0])
} catch (err) {
console.error(err);
showToast('⚠ Nepodarilo sa načítať príspevky.');
renderFeatured(null);
grid.innerHTML = `
<div class="empty-state">
<h3>Chyba pri načítaní</h3>
<p>Skontrolujte či beží backend server.</p>
</div>`;
}
}
loadPosts();
</script>
</body>
</html>

BIN
zadanie 1/frontend/logo_kps.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

401
zadanie 1/frontend/new-post.html Executable file
View File

@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nový príspevok</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #faf8f3;
--ink: #1c1917;
--ink-muted:#78716c;
--accent: #b45309;
--accent-lt:#fef3c7;
--rule: #e7e5e0;
--card-bg: #ffffff;
--mono: 'JetBrains Mono', monospace;
--serif: 'Lora', Georgia, serif;
--display: 'Playfair Display', Georgia, serif;
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--serif);
font-size: 1.0625rem;
line-height: 1.75;
min-height: 100vh;
}
/* ── HEADER ── */
header {
border-bottom: 1px solid var(--rule);
padding: 1.25rem clamp(1.5rem, 5vw, 4rem);
display: flex;
align-items: center;
justify-content: space-between;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-muted);
text-decoration: none;
transition: color 0.2s;
}
.back-link::before { content: '←'; }
.back-link:hover { color: var(--accent); }
.site-name {
font-family: var(--display);
font-size: 1.1rem;
font-weight: 700;
font-style: italic;
color: var(--accent);
text-decoration: none;
}
/* ── MAIN ── */
main {
max-width: 44rem;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 2rem) 6rem;
}
.page-label {
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.page-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--rule);
}
.page-title {
font-family: var(--display);
font-size: clamp(2rem, 5vw, 2.75rem);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 3rem;
}
.page-title em { font-style: italic; color: var(--accent); }
/* ── FORM ── */
.field {
margin-bottom: 2rem;
}
label {
display: block;
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-muted);
margin-bottom: 0.6rem;
}
input[type="text"],
select,
textarea {
width: 100%;
background: var(--card-bg);
border: 1px solid var(--rule);
border-radius: 2px;
padding: 0.85rem 1rem;
font-family: var(--serif);
font-size: 1rem;
color: var(--ink);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
appearance: none;
}
input[type="text"]:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-lt);
}
textarea {
min-height: 18rem;
resize: vertical;
line-height: 1.75;
font-size: 0.97rem;
}
.field-hint {
font-family: var(--mono);
font-size: 0.65rem;
color: var(--ink-muted);
margin-top: 0.4rem;
}
.char-count {
text-align: right;
font-family: var(--mono);
font-size: 0.65rem;
color: var(--ink-muted);
margin-top: 0.4rem;
}
/* ── ACTIONS ── */
.actions {
display: flex;
gap: 1rem;
align-items: center;
padding-top: 2rem;
border-top: 1px solid var(--rule);
}
.btn-primary {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 500;
background: var(--accent);
color: white;
border: none;
padding: 0.85rem 2rem;
cursor: pointer;
border-radius: 2px;
transition: background 0.2s, transform 0.1s;
}
.btn-primary:hover { background: #92400e; }
.btn-primary:active { transform: scale(0.98); }
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
background: none;
color: var(--ink-muted);
border: 1px solid var(--rule);
padding: 0.85rem 1.5rem;
cursor: pointer;
border-radius: 2px;
text-decoration: none;
transition: border-color 0.2s, color 0.2s;
}
.btn-secondary:hover { border-color: var(--ink-muted); color: var(--ink); }
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--ink);
color: var(--bg);
font-family: var(--mono);
font-size: 0.75rem;
padding: 0.75rem 1.25rem;
border-radius: 4px;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
#toast.show { opacity: 1; transform: translateY(0); }
/* ── ENTRANCE ── */
.fade-in { opacity: 0; animation: fadeUp 0.4s ease forwards; }
.delay-1 { animation-delay: 0.1s; }
.delay-2 { animation-delay: 0.2s; }
@keyframes fadeUp { to { opacity: 1; transform: none; } }
</style>
</head>
<body>
<header>
<a class="back-link" href="index.html">Späť na blog</a>
<a class="site-name" href="index.html">Môj Blog</a>
</header>
<main>
<p class="page-label fade-in" id="page-label">Nový príspevok</p>
<h1 class="page-title fade-in delay-1" id="page-title">Napíšte<br /><em>článok</em></h1>
<div class="fade-in delay-2">
<div class="field">
<label for="title">Nadpis príspevku *</label>
<input type="text" id="title" placeholder="Zadajte nadpis…" maxlength="200" required />
<div class="char-count"><span id="title-count">0</span> / 200</div>
</div>
<div class="field">
<label for="category">Kategória</label>
<select id="category">
<option value="">— Bez kategórie —</option>
<option value="Technológie">Technológie</option>
<option value="Cloud">Cloud</option>
<option value="Bezpečnosť">Bezpečnosť</option>
<option value="Sieťovanie">Sieťovanie</option>
<option value="Osobné">Osobné</option>
<option value="Iné">Iné</option>
</select>
</div>
<div class="field">
<label for="excerpt">Krátky popis (voliteľné)</label>
<input type="text" id="excerpt" placeholder="Krátky opis článku pre zoznam…" maxlength="300" />
<p class="field-hint">Zobrazí sa v zozname príspevkov. Ak nevyplníte, použije sa začiatok textu.</p>
</div>
<div class="field">
<label for="content">Text článku *</label>
<textarea id="content" placeholder="Začnite písať…"></textarea>
<div class="char-count"><span id="content-count">0</span> znakov</div>
</div>
<div class="actions">
<button class="btn-primary" id="btn-submit">Zverejniť príspevok</button>
<a class="btn-secondary" href="index.html">Zrušiť</a>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const API = '/api';
// Zistíme z URL či ideme editovať (?id=5) alebo vytvárať nový
// new-post.html → editMode = false → POST
// new-post.html?id=5 → editMode = true → PUT
const params = new URLSearchParams(location.search);
const editId = params.get('id'); // null ak nie je v URL
const editMode = editId !== null;
const titleInput = document.getElementById('title');
const contentInput = document.getElementById('content');
// Ak editujeme, zmeníme texty na stránke a načítame existujúce dáta
if (editMode) {
document.title = 'Upraviť príspevok — Môj Blog';
document.getElementById('page-label').textContent = 'Úprava príspevku';
document.getElementById('page-title').innerHTML = 'Upraviť<br /><em>článok</em>';
document.getElementById('btn-submit').textContent = 'Uložiť zmeny';
document.querySelector('.back-link').href = `post.html?id=${editId}`;
loadExistingPost();
}
// Načíta dáta príspevku z API a predvyplní formulár
async function loadExistingPost() {
try {
const res = await fetch(`${API}/posts/${editId}`);
if (!res.ok) throw new Error();
const post = await res.json();
// Predvyplníme polia hodnotami z databázy
titleInput.value = post.title;
contentInput.value = post.content;
document.getElementById('excerpt').value = post.excerpt || '';
document.getElementById('title-count').textContent = post.title.length;
document.getElementById('content-count').textContent = post.content.length;
// Nastavíme správnu kategóriu v selecte
if (post.category) {
document.getElementById('category').value = post.category;
}
} catch {
showToast('Nepodarilo sa načítať príspevok.');
}
}
// Char counters
titleInput.addEventListener('input', () => {
document.getElementById('title-count').textContent = titleInput.value.length;
});
contentInput.addEventListener('input', () => {
document.getElementById('content-count').textContent = contentInput.value.length;
});
function showToast(msg, duration = 3500) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), duration);
}
function validate() {
if (!titleInput.value.trim()) {
showToast('Prosím zadajte nadpis.');
titleInput.focus();
return false;
}
if (!contentInput.value.trim()) {
showToast('Prosím napíšte text článku.');
contentInput.focus();
return false;
}
return true;
}
document.getElementById('btn-submit').addEventListener('click', async () => {
if (!validate()) return;
const btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.textContent = 'Ukladám…';
const payload = {
title: titleInput.value.trim(),
category: document.getElementById('category').value,
excerpt: document.getElementById('excerpt').value.trim(),
content: contentInput.value.trim(),
};
try {
// editMode → PUT na existujúci príspevok, inak → POST nový
const url = editMode ? `${API}/posts/${editId}` : `${API}/posts`;
const method = editMode ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const saved = await res.json();
showToast(editMode ? '✓ Zmeny boli uložené!' : '✓ Príspevok bol zverejnený!');
setTimeout(() => window.location.href = `post.html?id=${saved.id}`, 1200);
} catch (err) {
console.error(err);
showToast('Nepodarilo sa uložiť. Skúste znova.');
btn.disabled = false;
btn.textContent = editMode ? 'Uložiť zmeny' : 'Zverejniť príspevok';
}
});
</script>
</body>
</html>

345
zadanie 1/frontend/post.html Executable file
View File

@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Príspevok — Môj Blog</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #faf8f3;
--ink: #1c1917;
--ink-muted:#78716c;
--accent: #b45309;
--accent-lt:#fef3c7;
--rule: #e7e5e0;
--card-bg: #ffffff;
--mono: 'JetBrains Mono', monospace;
--serif: 'Lora', Georgia, serif;
--display: 'Playfair Display', Georgia, serif;
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--serif);
font-size: 1.0625rem;
line-height: 1.8;
min-height: 100vh;
}
/* ── HEADER ── */
header {
border-bottom: 1px solid var(--rule);
padding: 1.25rem clamp(1.5rem, 5vw, 4rem);
display: flex;
align-items: center;
justify-content: space-between;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-muted);
text-decoration: none;
transition: color 0.2s;
}
.back-link::before { content: '←'; }
.back-link:hover { color: var(--accent); }
.site-name {
font-family: var(--display);
font-size: 1.1rem;
font-weight: 700;
font-style: italic;
color: var(--accent);
text-decoration: none;
}
/* ── ARTICLE ── */
article {
max-width: 44rem;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 2rem) 6rem;
}
.article-category {
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-lt);
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 2px;
margin-bottom: 1.5rem;
}
.article-title {
font-family: var(--display);
font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
}
.article-meta {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.25rem 0;
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
margin-bottom: 3rem;
}
.meta-item {
font-family: var(--mono);
font-size: 0.68rem;
color: var(--ink-muted);
letter-spacing: 0.06em;
}
.meta-label {
display: block;
font-size: 0.58rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--ink-muted);
opacity: 0.6;
margin-bottom: 0.2rem;
}
.meta-divider {
width: 1px;
height: 2rem;
background: var(--rule);
}
/* ── BODY TEXT ── */
.article-body {
font-size: 1.05rem;
line-height: 1.85;
white-space: pre-wrap;
word-break: break-word;
}
.article-body p { margin-bottom: 1.5rem; }
/* ── DELETE BUTTON ── */
.danger-zone {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--rule);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.btn-edit {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
background: none;
border: 1px solid var(--accent);
padding: 0.5rem 1.25rem;
cursor: pointer;
border-radius: 2px;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.btn-edit:hover { background: var(--accent); color: white; }
.btn-delete {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #dc2626;
background: none;
border: 1px solid #dc2626;
padding: 0.5rem 1.25rem;
cursor: pointer;
border-radius: 2px;
transition: background 0.2s, color 0.2s;
}
.btn-delete:hover { background: #dc2626; color: white; }
/* ── SKELETON ── */
.skeleton {
background: linear-gradient(90deg, var(--rule) 25%, #ede9e2 50%, var(--rule) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 3px;
}
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
/* ── ERROR STATE ── */
.error-state {
text-align: center;
padding: 6rem 2rem;
}
.error-state h2 {
font-family: var(--display);
font-size: 2rem;
font-style: italic;
color: var(--ink-muted);
margin-bottom: 1rem;
}
.error-state a {
color: var(--accent);
}
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--ink);
color: var(--bg);
font-family: var(--mono);
font-size: 0.75rem;
padding: 0.75rem 1.25rem;
border-radius: 4px;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
#toast.show { opacity: 1; transform: translateY(0); }
/* ── ENTRANCE ── */
.fade-in { opacity: 0; animation: fadeUp 0.5s ease forwards; }
@keyframes fadeUp { to { opacity: 1; transform: none; } }
</style>
</head>
<body>
<header>
<a class="back-link" href="index.html">Späť na blog</a>
<a class="site-name" href="index.html">Môj Blog</a>
</header>
<article id="article-root">
<!-- loaded by JS -->
<div style="display:flex;flex-direction:column;gap:1rem;margin-top:1rem;">
<div class="skeleton" style="height:1.1rem;width:5rem;"></div>
<div class="skeleton" style="height:3rem;width:90%;"></div>
<div class="skeleton" style="height:3rem;width:65%;"></div>
<div class="skeleton" style="height:1rem;width:100%;margin-top:1rem;"></div>
<div class="skeleton" style="height:1rem;width:100%;"></div>
<div class="skeleton" style="height:1rem;width:80%;"></div>
</div>
</article>
<div id="toast"></div>
<script>
const API = '/api';
function formatDate(iso) {
return new Date(iso).toLocaleDateString('sk-SK', {
day: 'numeric', month: 'long', year: 'numeric'
});
}
function readingTime(text) {
const words = text.trim().split(/\s+/).length;
return Math.max(1, Math.round(words / 200));
}
function showToast(msg, duration = 3000) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), duration);
}
function renderPost(post) {
document.title = `${post.title} — Môj Blog`;
document.getElementById('article-root').innerHTML = `
<span class="article-category fade-in">${post.category || 'Bez kategórie'}</span>
<h1 class="article-title fade-in">${post.title}</h1>
<div class="article-meta fade-in">
<div class="meta-item">
<span class="meta-label">Dátum</span>
${formatDate(post.created_at)}
</div>
<div class="meta-divider"></div>
<div class="meta-item">
<span class="meta-label">Čítanie</span>
${readingTime(post.content)} min
</div>
${post.updated_at ? `
<div class="meta-divider"></div>
<div class="meta-item">
<span class="meta-label">Upravené</span>
${formatDate(post.updated_at)}
</div>` : ''}
</div>
<div class="article-body fade-in">${post.content}</div>
<div class="danger-zone fade-in">
<a class="btn-edit" href="new-post.html?id=${post.id}">Upraviť príspevok</a>
<button class="btn-delete" id="btn-delete">Odstrániť príspevok</button>
</div>`;
document.getElementById('btn-delete').addEventListener('click', () => deletePost(post.id));
}
async function deletePost(id) {
if (!confirm('Naozaj chcete odstrániť tento príspevok?')) return;
try {
const res = await fetch(`${API}/posts/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showToast('Príspevok bol odstránený.');
setTimeout(() => window.location.href = 'index.html', 1500);
} catch {
showToast('⚠ Nepodarilo sa odstrániť príspevok.');
}
}
async function loadPost() {
const params = new URLSearchParams(location.search);
const id = params.get('id');
if (!id) {
document.getElementById('article-root').innerHTML = `
<div class="error-state">
<h2>Príspevok nenájdený</h2>
<p><a href="index.html">Späť na domovskú stránku</a></p>
</div>`;
return;
}
try {
const res = await fetch(`${API}/posts/${id}`);
if (res.status === 404) throw new Error('not_found');
if (!res.ok) throw new Error();
const post = await res.json();
renderPost(post);
} catch (err) {
const msg = err.message === 'not_found'
? 'Príspevok neexistuje alebo bol odstránený.'
: 'Nepodarilo sa načítať príspevok.';
document.getElementById('article-root').innerHTML = `
<div class="error-state">
<h2>${msg}</h2>
<p><a href="index.html">Späť na domovskú stránku</a></p>
</div>`;
}
}
loadPost();
</script>
</body>
</html>

42
zadanie 1/nginx.conf Executable file
View File

@ -0,0 +1,42 @@
# =============================================================
# nginx.conf Konfigurácia webservera Nginx
# =============================================================
# Nginx plní dve úlohy naraz:
# 1. Servuje statické HTML/CSS/JS súbory frontendu
# 2. Funguje ako reverse proxy preposiela /api/* requesty na backend
#
# Reverse proxy = Nginx stojí pred backendom a preposiela mu requesty
# Klient (prehliadač) vidí iba Nginx na porte 80, backend je skrytý
# =============================================================
server {
# Počúvame na porte 80 (štandardný HTTP port)
listen 80;
# Koreňový adresár so statickými súbormi frontendu
root /usr/share/nginx/html;
# Predvolený súbor keď sa otvorí / (koreň webu)
index index.html;
# ── Statické súbory (frontend) ────────────────────────────
# Každá URL, ktorá nezačína /api, sa obsluží ako statický súbor
location / {
# try_files: skús súbor adresár vráť index.html
# Posledné "/" zabezpečí, že vždy vrátime index.html
try_files $uri $uri/ /index.html;
}
# ── Reverse proxy pre API ──────────────────────────────────
# Všetky requesty začínajúce /api sa presmerujú na backend
location /api/ {
# "backend" je názov služby v Docker Compose
# Docker vyrieši toto meno na IP adresu backend kontajnera
proxy_pass http://backend:3000;
# Hlavičky informujeme backend o pôvodnom requeste
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

15
zadanie 1/prepare-app.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
# =============================================================
# prepare-app.sh Príprava aplikácie
# =============================================================
# Zostaví Docker obraz backendu.
# Sieť a zväzok vytvorí Docker Compose automaticky pri štarte.
# =============================================================
echo "Pripravujem aplikáciu..."
# Zostav obraz backendu podľa ./backend/Dockerfile
# --no-cache = vždy čerstvý build, ignoruje cache
docker compose build --no-cache
echo "Príprava dokončená."

18
zadanie 1/remove-app.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# =============================================================
# remove-app.sh Úplné odstránenie aplikácie
# =============================================================
# Odstráni VŠETKO čo vytvoril prepare-app.sh a start-app.sh:
# - kontajnery
# - sieť blog-network
# - zväzok db-data (POZOR: dáta z DB sa stratia!)
# - zostavené Docker obrazy
# =============================================================
echo "Odstraňujem aplikáciu..."
# --volumes = zmaže aj pomenované zväzky (dáta DB)
# --rmi all = zmaže aj zostavené obrazy
docker compose down --volumes --rmi all
echo "Aplikácia bola odstránená."

26
zadanie 1/start-app.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# =============================================================
# start-app.sh Spustenie aplikácie
# =============================================================
echo "Spúšťam aplikáciu..."
# -d = detached mode (kontajnery bežia na pozadí)
# Docker Compose automaticky vytvorí sieť a zväzok ak neexistujú
docker compose up -d
echo ""
echo "Aplikácia je dostupná na:"
echo " Blog: http://localhost:80"
echo " pgAdmin: http://localhost:8080"
echo ""
echo "Prihlasovacie údaje pgAdmin:"
echo " Email: admin@blog.local"
echo " Heslo: admin"
echo ""
echo "Pripojenie k DB v pgAdmine:"
echo " Host: db"
echo " Port: 5432"
echo " Database: blog"
echo " User: blog_user"
echo " Heslo: blog_pass"

17
zadanie 1/stop-app.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# =============================================================
# stop-app.sh Pozastavenie aplikácie
# =============================================================
# Zastaví kontajnery ale ZACHOVÁ:
# - zväzok db-data (dáta databázy)
# - sieť blog-network
# - zostavené obrazy
# Po stop-app.sh môžete znova spustiť start-app.sh
# a aplikácia bude presne v takom stave, v akom ste ju nechali.
# =============================================================
echo "Zastavujem aplikáciu..."
docker compose stop
echo "Aplikácia zastavená. Dáta sú zachované."