Návod jak vytvořit blog

Blog, to je dnes už webová klasika, „hello world“ všech webových řešení. Pojďme se v rychlosti podívat na to, jak takový jednoduchý blog vytvořit v Nette.

Požadavky

  • Pochopení základních principů Nette (vztahy v MVP vzoru, základní představa o funkci presenteru a jeho vztahu k šablonám)
  • Povrchní znalost dibi
  • Prostředí pro běh Nette

Úvod

Tvorba jednoduchého blogu je evergreenem mezi tématy tutoriálů různých webových frameworků. Bohužel, většina tutoriálů je okleštěna na minimální kostru úkolu a jako hlavní cíl si kladou ukázat uživateli, jak jednoduše to s daným frameworkem jde. Tím se náročnost úlohy snižuje na úroveň, na které téměř nestojí za to nějaký framework používat. Neberte tedy tento tutoriál jako lákaldo na Nette, ale jako výukový materiál.
V tutoriálu je použita namespacová verze Nette. Přestože aplikace je tak malá, že namespaces jsou tu spíš na obtíž, je to něco, co by každý potenciální uživatel Nette měl znát a umět používat. Pokud z technických důvodů nemůžete použít verzi pro PHP =>5.3, použijte bezprefixovou verzi Nette pro PHP 5.2 a vypusťte z uvedených zdrojových kódu definice namespaces.

Blog za 19 minut!

Pomocí RoR nebo CodeIgniteru lze vytvořit „kompletní“ „blog“ za „20“ minut. Vážení, to nic není! Předvedu Vám, že s Nette to za 19 minut dokážete i Vy!

Instalace

Použijeme skeleton z ditribuce. Nakopírujte ho do požadované složky na serveru. Do složky /libs nakopírujeme dibi (v distribuci Nette se nachází ve složce 3rdParty). Budeme také potřebovat databázi, já se budu držet MySQL, ale pokud chcete použít SQLite nebo PostgreSQL, není to problém, stačí číst dál.

Nezapomeňte, že ve skeletonu chybí Nette ve složce /libs!

Databáze a model

Začněmě s tvorbou příslušných tabulek. Jejich struktura je jasná ze zadání. Spustíme tedy na naši databázi tyto příkazy:

CREATE TABLE `posts` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `title` varchar(128) COLLATE utf8_bin NOT NULL,
    `body` text COLLATE utf8_bin NOT NULL,
    `date` datetime NOT NULL,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

Tím vytvoříme tabulku s články.

CREATE TABLE `comments` (
    `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY ,
    `post_id` INT NOT NULL ,
    `author` VARCHAR( 128 ) NOT NULL ,
    `body` TEXT NOT NULL ,
    `date` DATETIME NOT NULL,
    INDEX (post_id),
    FOREIGN KEY (post_id) REFERENCES posts(id)
) ENGINE = INNODB CHARACTER SET utf8 COLLATE utf8_bin;

A tímto (překvapivě) tabulku komentářů.

Pokud používáte jinou databázi, vytvořte stejnou strukturu tabulek, dibi se postará o kompatibilitu na straně aplikace samo.

Když máme databázi, je potřeba se k ní skrz dibi připojit. Konfigurační soubory v Nette nabízejí elegantní způsob správy dat jako jsou údaje o databázi. Otevřete si config.ini ve složce /app a do části [common] přidejte příslušnou modifikaci těchto řádků:

db.server = localhost
db.database = blogtut
db.username = blogtut
db.password = blogtut
db.driver = mysqli
db.charset = utf8
db.lazy = true

A nyní připojení samotné. Do souboru bootstrap.php (zaváděcí soubor celé aplikace, jak už byste měli vědět) přidejte před řádek

$application->run(); // Tento řádek spustí naši aplikaci...

toto:

dibi::connect(Environment::getConfig('db')); // ...a připojit se potřebujeme před spuštěním aplikace

Zde se na chvíli zastavíme a dáme si minutku teorie, ve které prohloubíme znalosti a odpovíme na možné otázky. Několik zásadních bodů:

$db = [
    'server' => 'localhost',
    'database' => 'blogtut',
    'username' => 'blogtut',
    'password' => 'blogtut',
    'driver' => 'mysql',
    'charset' => 'utf8',
    'lazy' => true
];
  • I když spojení s databází nastavujeme pokaždé, když se aplikace spustí, skutečné spojení probíhá jen tehdy, kdy je to skutečně potřeba, a to díky nastavení db.lazy = true v config.ini.

Musíme ještě vytvořit modely pro obě tabulky.

<?php

class PostsModel
{
    public static function fetchAll()
    {
        return dibi::fetchAll('
            SELECT *
            FROM [posts]
            ORDER BY [date]', dibi::DESC
        );
    }
}
<?php

class CommentsModel
{
    public static function fetchAll($post_id)
    {
        return dibi::fetchAll('
            SELECT *
            FROM [comments]
            WHERE [post_id] = %i', $post_id
        );
    }
}

Pro srozumitelnost budou prozatím jejich metody bez zbytečných ohledů tahat všechna dostupná data. Oba soubory s definicí tříd uložte do složky /app/models a pojmenujte je podle třídy, kterou obsahují (PostsModel.php, CommentsModel.php).

Pokud jste zvyklí ukončovat skripty značkou ?>, tak si rychle odvykněte! Ukončovací tagy jsou nepovinné a způsobují pouze problémy s netisknutelnými znaky, což poté způsobí nemožnost odeslání HTTP hlaviček.

Presenter

V /app/presenters/ je HomepagePresenter. Ten poslouží jako dobrý základ našeho snažení. Přidáme do něj metodu, která vezme data z modelu a předá je do template k vykreslení.
Metodu renderDefault upravíme na

public function renderDefault()
{
    $this->template->posts = PostsModel::fetchAll();
}

Pokud si nejste jistí, proč pracuji s metodou default v presenteru Homepage, podívejte se na routy v souboru bootstrap.php.

View

Výborně, teď máme v view default dostupnou proměnnou $posts, která obsahuje všechny příspěvky. Pojďme je vypsat.
Ve složce /app/templates je soubor @layout.phtml. Ten obsahuje základní rámec všech stránek, které budeme tvořit. Proto doporučuji si ho prohlédnout.
V /app/templates/Homepage je soubor default.phtml, který obsahuje definici bloku content, jehož obsah nahradí {include #content} v layoutu. Výpis všech článků může vypadat třeba takhle:

{block content}
<h1>Můj blogísek</h1>
<div id="posts">
    {if count($posts)}
        {foreach $posts as $post}
        <div class="post">
            <h3>{$post['title']}</h3>
            <small>Přidáno {$post['date']|date}</small>
            <p>{$post['body']}</p>
        </div>
        {/foreach}
    {else}
        Zatím nebyl napsán žádný článek.
    {/if}
</div>

Stáhněte si testovací data, nahrajte je do databáze a zkuste otevřít root webu ve vašem prohlížeči. Výsledek by měl vypadat takto:

Dovolím si zkazit radost povinnou trochou teorie:

  • Použité příkazy ve složených závorkách se nazývají makra Latte filtru a víc se o nich dozvíte v dokumentaci.
  • Všiměte si části {$post['date']|date}. Ono date za vertical barem (svislítkem, chcete-li) je helper. Helper je jednoduchá funkce, která provádí s dannou proměnnou nějakou operaci podstatnou pouze pro zobrazení.

Komentáře

To ani nebolelo a zabralo to jen pár minut, ale blog je o komunikaci s lidmi. Proto potřebujeme přidat možnost komentovat příspěvky. Klasiciký přístup je takový, že na titulní straně se zobrazuje jen začátek textu s odkazem na celý text, kde je i možnost komentovat. Pojďme tedy na to. Nová metoda presenteru Protože zobrazení samostatného příspěvku nijak nesouvisí s titulní stranou, přidáme do našeho preseneru novou metodu:

public function renderSingle($id = 0)
{
    $this->template->post = PostsModel::fetchSingle($id);
}

A vytvoříme příslušnou metodu v PostsModel:

public static function fetchSingle($id)
{
    return dibi::fetch('
        SELECT *
        FROM [posts]
        WHERE [id] = %i', $id
    );
}

Měli byste znát rozdíl mezi dibi::fetchAll a dibi::fetch. První vrací kolekci DibiRow, která implementuje ArrayAccess, druhá vrací přímo DibiRow.

Samozřejmě není optimální pro každý typ požadavku psát samostatnou funkci v modelu, máme na to různé fígly, ale prozatím KISS.

Také musíme vytvořit template pro tento požadavek, takže do /app/templates/Homepage/single.phtml vložíme:

{block content}
<div class="post">
    <h1>{$post['title']}</h1>
    <small>Přidáno {$post['date']|date}</small>
    <p>{$post['body']}</p>
</div>

Nyní můžete v prohlížeči zkusit otevřit třeba /Homepage/single/2.

Opět se vracíme k routám. Podívejte se ještě jednou do bootstrap.php.

Odkazy

Aby se sem dostal i běžný uživatel, potřebujeme nějaké odkazy z hlavní stránky. K tomu slouží makro {llink …}. Předělejme tedy view titulní stránky:

{block content}
<h1>Můj blogísek</h1>
<div id="posts">
    {if count($posts)}
        {foreach $posts as $post}
        <div class="post">
            <h3>{$post['title']}</h3>
            <small>Přidáno {$post['date']|date}</small>
            <p>{$post['body']|truncate:300}</p>
            <a href="{link single $post['id']}">Více…</a>
        </div>
        {/foreach}
    {else}
        Zatím nebyl napsán žádný článek.
    {/if}
</div>

V naší pravidelné minutovce teorie bych nyní rád vyzdvihl dvě věci:

  • Všimněte si helperu truncate:300 a jeho efektu.
  • Zápis {plink single $post['id']} znamená: vytvoř odkaz na akci single aktuálního presenteru a přidej parametr $post['id']. Je ekvivalentní se zápisem {plink Homepage:single $post['id']}. Je důležité, že nezapisujeme žádné URL, ale odkaz na akci presenteru.
  • URL je zpětně vytvořeno tak, aby odpovídalo routám v bootstrap.php a naše aplikace je tím pádem na jeho tvaru naprosto nezávislá.

Formulář

Konečně se dostáváme k něčemu „záživnějšímu“ – pojďme si vytvořit formulář na odesílání komentářů! Nette má několik způsobů jak řešit formuláře, od tvrdého nakódování do templatu a odděleného zpracování vstupů po sofistikované metody jako AppForm. Třída AppForm nabízí výhody, o kterým se mnohým ani nesnilo. Náš formulář bude samostatnou komponentou. Pokud jde o tvorbu komponent, používá „továrničky“, které vyrobí komponentu až v momentě, kdy je to skutečně potřeba. Do HomepagePresenter přidáme klauzuli use a dvě funkce:

use Nette\Application\AppForm;
public function createComponentCommentForm($name)
{
    $form = new AppForm($this, $name);
    $form->addText('author', 'Jméno')
            ->addRule(AppForm::FILLED, 'To se neumíš ani podepsat?!');
    $form->addTextArea('body', 'Komentář')
            ->addRule(AppForm::FILLED, 'Komentář je povinný!');
    $form->addSubmit('send', 'Odeslat');
    $form->onSubmit[] = [$this, 'commentFormSubmitted'];
    return $form;
}

public function commentFormSubmitted(AppForm $form)
{
    $data = $form->getValues();
    $data['date'] = new DateTime();
    $data['post_id'] = (int) $this->getParam('id');
    $id = CommentsModel::insert($data);
    $this->flashMessage('Komentář uložen!');
    $this->redirect("this#comment-$id");
}

První z nich zpracovává odeslaný formulář (všimněte si přesměrování, které zajistí, aby uživatel neodeslal formulář vícekrát kliknutím na tlačítko Obnovit), druhá je zmíněná továrnička.

Za pozornost stojí volání ‚$this->flashMessage('Komentář uložen!‘)‚. Nette obsahuje tzv. flash zprávičky, což jsou krátké zprávy které uživatele informují o aktuálním stavu aplikace. Defaultně jsou vypisovány v '@layout.phtml‘.

Do CommentsModel musíme přidat použitou metodu:

public static function insert($data)
{
    dibi::query('
        INSERT INTO [comments]', $data
    );

    return dibi::getInsertId();
}

A také nesmíme zapomenout předat všechny komentáře k příslušnému příspěvku do šablony, takže metodu renderSingle upravíme:

public function renderSingle($id = 0)
{
    if (!($post = PostsModel::fetchSingle($id))) {
        $this->redirect('default'); //pokud clanek neexistuje, presemerujeme uzivatele
    }
    $this->template->post = $post;
    $this->template->comments = CommentsModel::fetchAll($id);
}

Poslední věc, která zbývá, je úprava naší šablony:

{block content}
<a href="{link default}">&lt;&lt; home </a>
<div class="post">
    <h1>{$post['title']}</h1>
    <small>Přidáno {$post['date']|date}</small>
    <p>{$post['body']}</p>
</div>

<h3>Komentáře:</h3>
<div id="comments">
    {if count($comments)}
        <div id="comment-{$comment->id}" class="commment" n:foreach="$comments as $comment">
            <p>{$comment['body']}</p>
            <small>{$comment['author']}, {$comment['date']|date}</small>
            <hr>
        </div>
    {else}
        Ke článku zatím nebyly napsány žádné komentáře. Buďte první!
    {/if}
</div>

{control commentForm}

Ale pozor! Tady jaksi chybí {foreach ...}, že? Místo něho jsou použity tzv. „n-atributy“, které v některých případech nabízejí alternativní syntaxy zápisu Latté maker.

Všimněte si, že formulář si sám najde cestu do šablony a vykreslí se. Zkuste odeslat formulář nevyplněný. Jak vidíte, Nette vygenerovalo validační Javascript k našemu formuláři. Ale validace probíhá i na straně serveru, takže vypnutý Javascript její funkčnost neovlivní.

Ve starších příkladech se místo makra control můžete setkat s jeho starším aliasem widget.

Výsledný zdrojový kód je ke stažení zde.

Doufám, že jste se něco přiučili. Pokud už se v Nette trochu vyznáte a chcete další náměty, zkuste:

  • Vypsat počet příspěvků/komentářů na hlavní stránku
  • Přidat pár statických stránek a odkazy na ně
  • Vytvořit jednoduchou administraci
  • K administraci přidejte uživatelské účty
  • Přidat různé typy účtů, kontrolu oprávnění pomocí ACL
  • Upravit routování a generovat „cool URL“

Sám se těmto úlohám budu věnovat v budoucích tutoriálech.

Stáhněte si zdrojový kód: https://github.com/…pball/master