hotovy projekt
This commit is contained in:
parent
68341ad657
commit
6cdbf74786
156
zadanie 1/README.md
Executable file
156
zadanie 1/README.md
Executable 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
24
zadanie 1/backend/Dockerfile
Executable 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
37
zadanie 1/backend/db.js
Executable 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
28
zadanie 1/backend/init.sql
Executable 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
14
zadanie 1/backend/package.json
Executable 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
181
zadanie 1/backend/server.js
Executable 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
130
zadanie 1/docker-compose.yaml
Executable 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
494
zadanie 1/frontend/index.html
Executable 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">© <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
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
401
zadanie 1/frontend/new-post.html
Executable 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
345
zadanie 1/frontend/post.html
Executable 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
42
zadanie 1/nginx.conf
Executable 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
15
zadanie 1/prepare-app.sh
Executable 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
18
zadanie 1/remove-app.sh
Executable 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
26
zadanie 1/start-app.sh
Executable 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
17
zadanie 1/stop-app.sh
Executable 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é."
|
||||
Loading…
Reference in New Issue
Block a user