Blog#
Redakční obsah webu — články, novinky, akce, galerie, karusely, kniha návštěv
a kontaktní formulář. Kód: app/UI/Api/Blog/, front app/UI/Front/Blog/.
Blog je zároveň referenční modul systému: byl první zapojený do Elasticsearch a jeho front presenter je vzorem rozkladu do traitů. Kdo hledá, jak se v tomhle CMS něco dělá správně, začíná tady.
Z čeho se modul skládá#
| Skupina | Entity | Poznámka |
|---|---|---|
| obsah | Article, News, Event, Intro + jejich *Text |
každý má vlastní kategorii i výpis |
| média | Gallery + GalleryAssetRel, Carousel + CarouselItem |
|
| taxonomie | Category (strom), Tag (plochý seznam) |
|
| interakce | Guestbook + GuestbookMessage, ContactMessage, ArticleDiscussionMessage |
|
| doplňky | FlashMessage, ArticlePollRel, ArticleAdvertRel |
provázání na ankety a reklamu |
Dva vzory, ze kterých se kopíruje#
| Vzor | Soubor | Proč zrovna tenhle |
|---|---|---|
| zdroj pro Elasticsearch | app/UI/Api/Blog/Models/Search/ArticleDocumentSource.php |
článek byl první zapojený obsah; dnes jsou zdroje čtyři a všechny z něj vycházejí |
| rozklad presenteru do traitů | app/UI/Front/Blog/Presenters/Traits/ — Inits/, Lists/, Details/ |
jeden z mála presenterů, který má rozdělení hotové podle konvence |
Podrobně Elasticsearch a Konvence kódu.
Nekopírujte vzory z knihy návštěv
Je to nejstarší část modulu a část jejího kódu je ještě v podobě předchozí generace. Vypadá to jako platný vzor, protože je to týž modul — ale není.
Kategorie a štítky#
Kategorie tvoří strom a promítají se do adres; štítky jsou plochý seznam a jen označují.
Štítek je levnější než kategorie
Kategorie mění strukturu i URL — a s tím i překlady, sitemapy a odkazy zvenčí. Když stačí označit, použijte štítek.
Kategorie blogu mají GLOBÁLNĚ unikátní href
cms_mod_blog_category_texts a cms_mod_blog_article_category_texts mají
UNIQUE(href) — bez jazyka. Ostatní kategoriové tabulky v systému mají
správně UNIQUE(href, language_id). Dvě kategorie v různých jazycích tu proto
nemohou mít stejnou adresu, a čeština se slovenštinou sdílí spoustu slov
(doprava, zahrada). Projeví se to až při zakládání druhého jazyka jako
Duplicate entry 'doprava' for key 'ind_href' — před vložením se proto musí
kontrolovat kolize bez filtru na jazyk.
Odkazy kategorií a regiony#
Články, novinky a události jsou tři samostatné stránky (id 50, 52, 54) se třemi
presentery, kategorie je ale sdílená. Cíl odkazu na kategorii proto určuje
app/UI/Front/Blog/Components/Traits/BlogListTargetTrait.php:
| Metoda | Co dělá |
|---|---|
blogListTarget($type) |
podle typu položky (article, news, event) vrátí cíl a id stránky |
blogListTargetForPage($presenter) |
totéž podle aktuální stránky (52/53 = novinky, 54/55 = události, jinak články) |
blogListParams($slug, $categoryId, $regionId) |
parametry odkazu; s vybraným regionem je segment kategorie vždy výslovný (- = žádná), jinak by se region posunul na místo kategorie |
blogRegionLinks($presenter, $type, $items, $dispatcher) |
odkazy /<výpis>/-/<region> na výpis téhož typu filtrovaný regionem položky |
Parametr kategorie je categoryId (maska rout 101/102/104), ne category. Dřív odkazy
na kategorii vedly vždy na články (a vyráběly /clanky/o-1?category=109), a číselná
adresa /udalosti/1 padala na TypeError v CategoryTrait. Teď:
EventListRouteFactoryregistruje překlad pod klíčemcategoryId,NewsListRouteFactoryho má odkomentovaný,CategoryTraitpřetypuje id naint; neznámé číselné id kanonizuje 301 na slug,- karty, detail novinky,
CategoryList,CategoryChildrenLista drobečky volajíBlogListTargetTrait.
Zbývají odkazy bez categoryId
Několik míst, které nikdo nevkládá (Article/parts/*, BreadcrumbComponent/CategoryTrailTrait
vede vždy na články) a starý CategoryList Blogu s tím ještě počítají. Přepínač modulů
v template1/…/shared/main.latte přidává k odkazu na Blog ?category=<id>, které Blog
ignoruje.
Region u článků a novinek#
Region měla dřív jen Event (sloupec, Picker v administraci, filtr včetně podstromu, routa
/udalosti/<kat>/<region>/o-<page>). Article a News ho získaly stejným vzorem.
| Vrstva | Co |
|---|---|
| SQL | sql/2026-10-01_01_blog_article_news_region.sql: sloupec cms_system_regions_region_id u článků a novinek, index a cizí klíč ON DELETE SET NULL (rollback …_91_). Existující záznamy zůstávají bez regionu |
| Entity | ve čtyřech vrstvách (Article, News) — asociace region + skalár regionId |
| Administrace | RegionPickerTrait v presenterech, skryté pole regionId plní Picker; v Article/News/EventFormTrait se při uložení nastavuje setRegion() i setRegionId() |
| Front | Blog\RegionTrait (initCurrentRegion(), prepareRegionFilter()), filtr region.lft ≥ … ∧ region.rgt ≤ … včetně podstromu |
| Routa | překlad regionId v ArticleListRouteFactory a NewsListRouteFactory (masky segment měly) |
| Výpis | include region.translations ve výpisech, detailech a v ArticleListProvider, TaggedArticleList, NewsComponent |
Region nešel z formuláře zrušit, dokud se nenastavilo i setRegionId()
extract posílá do Api starý skalár regionId načtené entity. Zrušení výběru
(setRegion(null)) bez setRegionId(null) se proto při uložení vrátilo. Nový formulář
s regionem musí nastavit obojí.
Záznam bez regionu se při zvoleném regionu nezobrazí
Je to záměr (stejné jako u kategorie); filtr je shora dolů. Neznámý slug regionu vrací výpis bez filtru, stejně jako u kategorie.
Název regionu u položky: řádkový (Style_1) a tabulkový (Style_3) výpis a detaily
(info-row) ho ukazují s odkazem; karetní styly se štítky (Style_2/4/5/6) ho záměrně
vynechávají, řádek štítků se ořezává. ArticleDocumentSource region do Elasticsearch
neindexuje — filtr jde přes databázi, stejně jako u inzerce.
Vícejazyčnost#
Texty jsou v podřízené entitě, jedna na jazyk (ArticleText, NewsText, …).
Prázdný překlad přebije náhradní jazyk
Uložený prázdný řetězec je platná hodnota, takže vyhraje nad fallbackem a prvek vyjde bez názvu. Buď přeložit, nebo řádek vůbec nezakládat — podrobně Překlady.
Textová entita má odvozenou identitu
Nemá vlastní klíč — je složený z rodiče a jazyka. Chybějící use ORM v takové
entitě se přitom projeví tiše: mapování se nenačte a entita se chová, jako by
sloupce neměla.
Komentáře, hodnocení a ankety#
Neřeší je blog sám — jsou to sdílené komponenty z modulu Base
(Comment, CommentThread, Rating, RatingThread, Poll).
Komentáře mají články, galerie, kniha návštěv a akce (události). Akce má typ vlákna
ThreadType::BlogEvent ('blog_event'): Api EventManager jí vlákno komentářů založí při
uložení (CommentThreadCreatorTrait) a starší akci bez vlákna ho doplní při jejím dalším
uložení; běžná úprava akce s vláknem nic nezakládá. Smazání akce smaže i její vlákno
(OwnedThreadDeleteTrait).
Změna sdílené komponenty zasáhne tři další moduly
Komentáře i hodnocení používá vedle blogu i e-shop (produkty), inzerce a katalog firem. Oprava „jen pro článek“ se projeví všude — a otestuje ji jen ten, kdo si na to vzpomene.
Uživatelský text je XSS past
addSimpleEditor() sám nic nesanitizuje. Rich text se musí purifikovat v Api
beforeSave, prostý text se vypisuje přes |breaklines. Podrobně
Formuláře.
Komponenta hodnocení na stránce#
Rating (app/UI/Front/Base/Components/RatingComponent/) hodnotí cokoli, co drží skalární
ratingThreadId; vlákno musí existovat. Vzhled určuje styl, buď setStyle() v
createComponentRating(), nebo varianta v šabloně ({control rating:review},
{control frontBaseRating:number $id}).
| Styl | Varianta | Co vykreslí |
|---|---|---|
STYLE_CLASSIC (výchozí) |
{control rating} |
interaktivní hvězdy, jeden klik = jedno hodnocení přes AJAX |
STYLE_STATIC |
:static |
průměr jen ke čtení |
STYLE_REVIEW |
:review |
souhrnné pruhy, tlačítko recenze a seznam recenzí |
STYLE_SUMMARY |
:summary |
souhrnné pruhy bez recenzí |
STYLE_REVIEWS |
:reviews |
jen seznam recenzí |
STYLE_REVIEWS_DETAILED |
:reviewsDetailed |
souhrn, filtr podle hvězd, řazení a seznam; souhrn vypne setShowSummary(false) |
STYLE_NUMBER |
:number |
jen číslo průměru, bez HTML |
STYLE_THUMBS |
:thumbs |
palec nahoru (hodnota 5) a dolů (hodnota 1); setThumbs(['up']) nechá jen „líbí se“ |
Kdo smí hodnotit, rozhoduje volající, ne komponenta:
| Nastavení | Co řídí |
|---|---|
setAllowAnonymous() |
smí hodnotit nepřihlášený (článek ano, profil a produkt ne) |
setAllowVoting() |
smí tenhle návštěvník recenzi napsat (e-shop: jen kupující sady, CheckoutManager::setBuyers()) |
setVerifiedBuyersResolver() |
odznak „Ověřený nákup“ u cizích recenzí |
Resolver je funkce list<int> → list<int>: komponenta ji zavolá jednou se všemi autory na
stránce a dostane zpět ty ověřené. Komponenta je sdílená a o objednávkách nic neví; bez
resolveru (blog, profil) odznak není. Právo napsat recenzi se tímhle neřeší.
Styl reviewsDetailed už souhrn obsahuje
Na stránce s STYLE_REVIEWS_DETAILED nevkládej ještě STYLE_SUMMARY, jinak se tatáž čísla
vypíšou dvakrát.
Souhrn hodnocení#
Výpisy nečtou jednotlivá hodnocení, ale souhrn na vláknu (RatingThread): rating_count,
rating_avg a count_1 až count_5, počítané jen z hodnocení s active = 1.
| Kdo souhrn přepočítá | Kdy |
|---|---|
RatingManager::afterSave() → recalcThread() |
po každém uložení hodnocení, jen jeho vlákno |
RatingManager::afterDelete() |
po smazání hodnocení (i dávkovém delete([…])), vlákna smazaných hodnocení, ve stejné transakci |
RatingManager::deleteAll() |
po hromadném mazání podle filtrů, dotčená vlákna |
RatingManager::updateColumns() |
po změně approved, deleted, value nebo threadId, vlákna před i po změně; jiné sloupce (např. userName) přepočet nespouští |
cron /cron/base/rating/recalc → recalcAllThreads() |
denně; jeden hromadný UPDATE přes všechna vlákna, pojistka proti rozjetí; vlákno bez aktivních hodnocení vynuluje na 0 |
Raw SQL souhrn nepřepočítá
Mazání i zápis sloupců přes manager souhrn přepočítají hned. Když hodnocení měníš
vlastním SQL mimo manager, zavolej po něm RatingManager::recalcThread($threadId),
jinak souhrn opraví až noční cron.
Jeden hlas na návštěvníka#
Opakované hodnocení a hlas o užitečnosti recenze hlídá DuplicateVoterGuardTrait::voterFilters()
(RatingManager::hasRated(), RatingVoteManager::hasVoted()):
| Kdo hlasuje | Podle čeho se hledá předchozí hlas |
|---|---|
| přihlášený | userId, IP se ignoruje (jeden účet = jeden hlas z jakéhokoli zařízení) |
| anonym | userId IS NULL AND ip = … |
| anonym bez IP | nelze rozhodnout, metoda vrátí null |
Unikátní index anonyma neohlídá
Index UNIQUE(…, user_id) dvě hodnoty NULL za shodné nepovažuje, takže anonym by bez
aplikační kontroly mohl hlasovat znovu a znovu. Nová hlasovací entita potřebuje stejnou
kontrolu v Api a null z voterFilters() musí číst jako „nelze ověřit“, ne „nehlasoval“.
Schválení nového komentáře a hodnocení#
Zda se nový komentář nebo hodnocení zveřejní hned, rozhoduje beforeSave() Api manageru
podle nastavení modulu Base (modul 2). V administraci jsou v Nastavení webu → sekce
Komentáře, hodnocení a newsletter.
| Klíč | Platí pro | Chybí-li řádek |
|---|---|---|
comment.useAutoApproval |
každý nový komentář (CommentManager) |
1 = schváleno hned |
rating.useAutoApprovalStars |
hodnocení jen hvězdičkami (RatingManager) |
0 = čeká na schválení |
rating.useAutoApprovalReviews |
slovní recenze — text, klady, zápory (Rating::isReview()) |
0 = čeká na schválení |
Front komponenta hodnocení smí oba klíče hodnocení přebít pro jednu instanci
(setOverrideRatingUseAutoApprovalStars(), setOverrideRatingUseAutoApprovalReview()); jde
to do $meta pod stejným klíčem a $meta má přednost před databází. Úpravy existujícího
záznamu se stav schválení netýká — jinak by každé uložení z administrace přepsalo ruční
schválení, odschválení i měkké smazání.
Výchozí hodnoty komentářů a hodnocení jsou opačné
Komentáře se odjakživa zveřejňovaly hned, proto chybějící řádek znamená 1. Kdo by
výchozí hodnotu sjednotil na 0, zapne tiše moderaci všem modulům a komentáře po
odeslání zmizí z webu. Moderovaný režim se zapíná uložením 0 do řádku.
Klíč recenzí je v množném čísle
rating.useAutoApprovalReviews, stejně jako …Stars. Čtení …Review by řádek nikdy
nenašlo a recenze by šly vždy do moderace, ať je přepínač nastavený jakkoli.
Brány při vložení#
Kontroly běží v beforeSave() Api manageru, protože jen tudy projde každý insert —
front, administrace přes ApiBridge i externí :Api:Base:*:save. Formulář může mít vlastní
hlášku navíc, ale brána v manageru je ta, která platí.
| Manager | Kontrola | Odpověď |
|---|---|---|
CommentManager |
kvóta zakládání obsahu (base-comment) |
RATE_LIMIT_EXCEEDED |
CommentManager |
rodič odpovědi patří do téhož vlákna (rejectForeignParent(), jeden dotaz na dvojici id + threadId) |
INVALID_VALUE, errorCode: commentParentThreadMismatch |
RatingManager |
vlákno hodnocení existuje | INVALID_DATA |
RatingManager |
přihlášený autor není majitelem hodnoceného objektu | INVALID_DATA, errorCode: selfRating |
RatingManager |
tentýž návštěvník tu ještě nehodnotil (hasRated(), viz výše) |
DB_DUPLICATE_ENTRY, errorCode: alreadyRated |
Komentář s rodičem z cizího vlákna ze stromu zmizí
threadId a parentId jsou dva nezávislé skaláry. Strom se načítá jen podle
threadId, takže rodič v načtené množině chybí a odpověď se nikde nevykreslí, přestože
v databázi je. Nové UI, které odpovědi zakládá, proto nesmí bránu v manageru obejít.
Odpověď na hodnocení: autor a hodnocený#
Hodnocení má dva různé lidi, kteří s ním smějí něco dělat, a každý má vlastní akci.
| Kdo | Odkud se zjistí | Smí | Akce a role |
|---|---|---|---|
| autor recenze | Rating::$userId (RatingManager::isOwnedBy()) |
upravit svou recenzi | :Api:Base:Rating:save, role owner |
| majitel hodnoceného objektu | z DB: hodnocení → vlákno → objekt → majitel (RatingManager::findRespondableOwner()) |
napsat nebo smazat odpověď (response) |
:Api:Base:Rating:saveReply, role receiver |
Odpověď zapisuje jen RatingManager::saveReply(); responseDatetime dosazuje server a se
smazáním odpovědi ho nuluje. Front se předem ptá internalActionCanRespondTo(), odpověď je v
data.canRespond a jiný tvar se čte jako false.
Generický save je obalený (RatingPresenter::internalActionSave()): když payload mění odpověď
proti stavu v DB (RatingManager::payloadChangesReply()), musí volající projít navíc
guardem saveReply. Bez toho by si autor přes save dopsal „odpověď prodejce“ do vlastní
recenze.
Payload save |
payloadChangesReply() |
|---|---|
bez klíče response |
false |
nové hodnocení s prázdnou odpovědí (front posílá response => null) |
false, bez dotazu do DB |
| nové hodnocení s odpovědí | true |
| úprava, normalizovaná odpověď se liší od uložené (i smazání) | true |
neznámé id |
true — rozhodne guard |
Majitel je dnes odvoditelný jen u profilu uživatele (ThreadType::SystemUser, vazba
User::$ratingThreadId). U ostatních typů (článek, produkt, inzerát katalogu…) vrací
findRespondableOwner() null a odpovědět nesmí nikdo kromě správce.
Postup: povolit odpověď u dalšího typu hodnoceného objektu
- Rozšiř
RatingManager::findThreadOwner()o větev pro novýThreadType: najdi objekt podle jeho sloupceratingThreadIda vrať jeho majitele (User). - Nic jiného neměň.
canRespondTo(), guardsaveReplyi obalsavese ptají téže metody, takže pravidlo platí všude naráz. Stejná metoda zároveň zakáže majiteli hodnotit vlastní objekt (selfRating). - Ověř odpověď majitele, zamítnutí autora recenze a zamítnutí cizího uživatele
(
RatingSaveReplyReceiverGateTestje vzor).
Nezaměňuj isOwnedBy() a canRespondTo()
Naivní rating.userId === jwt.userId u odpovědi dá přesně opačný výsledek: odpovídat
by mohl autor recenze sám sobě a hodnocený ne.
saveReply() nevolej mimo guardovanou akci
Manager vlastnictví znovu neověřuje — rozhodl o něm guard v presenteru, který jediný zná identitu z JWT. Volání odjinud autorizaci obejde.
Bez řádku oprávnění saveReply neodpoví nikdo
Chybí-li klíč :Api:Base:Rating:saveReply v databázi, externí cesta zamítne všechny
včetně správce. Nouzový režim internalMode: off front pustí, takže chyba se ukáže až po
přepnutí do enforce.
Hlas o užitečnosti a nahlášení recenze#
„Byla tahle recenze užitečná?“ a „Nahlásit“ zapisuje RatingVoteManager do jedné tabulky
(RatingVote, rozlišené kind). Volají se jen interní akce RatingPresenter — vote,
report a myVotes (jak návštěvník hlasoval u recenzí na stránce). Nemají action*() a
vlastní CRUD nad RatingVote záměrně neexistuje: generický save by přijal cizí userId i
libovolnou ip a obešel kontrolu duplicit.
| Situace | Odpověď |
|---|---|
| hodnocení neexistuje nebo není aktivní (neschválené, smazané) | ITEM_NOT_FOUND |
| autor hlasuje o vlastní recenzi | PERMISSION_DENIED |
anonym a rating.allowAnonymousReviewVote není 1 (chybí-li řádek, je to „ne“) |
PERMISSION_DENIED |
| opakovaný hlas nebo nahlášení | DB_DUPLICATE_ENTRY — odmítá se, nepřepisuje |
| úspěch | uloží se řádek a počet na hodnocení se zvýší v DB (incrementCount()), ne přepíše |
Nahlášení recenzi neskryje ani nesmaže; jen zvedne reports a správce si je vyfiltruje.
userId a ip musí volající dosadit ze serverové identity (front: session a
getRemoteAddress()), nikdy z payloadu.
Unikátní klíč nad (rating_id, ip) nepřidávej
Přihlášené jistí UNIQUE uq_rating_user i proti souběhu; anonymy hlídá aplikace podle
IP. Unikátní klíč nad IP by zablokoval dva přihlášené uživatele z jedné sítě.
Hlas v anketě#
PollPresenter::internalActionRegisterVote() (externě actionRegisterVote()) hydratuje celý
PollVote z payloadu, ale ip a insertDatetime vždy přepíše hodnotou ze serveru
ještě před hydratací. IP je součást primárního klíče a jediná ochrana proti opakovanému
hlasování.
#[ExternalReadonly] ve vlastní akci nic nehlídá
Ořez atributů běží jen v SaveTrait::saveEntityData() (generický save). Vlastní akce,
která hydratuje entitu z payloadu, musí serverem počítaná pole přepsat sama — jinak si
volající pošle voteData[ip] a hlasuje znovu a znovu.
Komentáře a query cache#
Čtení komentářů nezávisí na volajícím, takže CommentPresenter má query cache zapnutou
jako ostatní presentery a výpisy komentářů nechodí pokaždé do databáze.
Soukromou komunikaci nedávej mezi komentáře
Výsledek čtení by začal záviset na volajícím a wire-payload cache je na uživateli
nezávislá: správce by ji nahřál a host by četl cizí konverzaci, nebo naopak. Muselo by se
vrátit isQueryCacheEnabled(): false, filterReadPayload() a filtry v managerech i
presenterech. Soukromá komunikace dostává vlastní tabulku (vzor: komentáře práce v
modulu Moje firma, Soukromé zprávy).
Komentáře a hodnocení v editaci objektu#
Tab s komentáři nebo hodnoceními v editaci článku, produktu či inzerátu vykreslují sdílené
admin komponenty CommentThread a RatingThread. Komponenta je klíčovaná id vlákna, takže
ji vloží kterýkoli editační formulář, který vlákno má. Přidání a úprava běží ve sdíleném
modálním okně administrace, mazání je měkké (příznak deleted).
Vlákno jde do formuláře ve skrytém poli threadId. To pole si ale klient může přepsat, takže
je jen ozvěna: o tom, ke kterému vláknu se zapisuje, rozhoduje serverový kontext, který
hostitel komponentě předá při vytvoření.
| Kde | Co se kontroluje | Když nesouhlasí |
|---|---|---|
signály add…!, edit…!, toggleDeleted! |
threadId z URL = kontext hostitele |
403 |
| odeslání formuláře | skryté threadId = kontext; rodič odpovědi a upravovaný záznam se hledají filtrem id + threadId |
nic se neuloží, modál ukáže chybu |
| bez kontextu | nic | komponenta nic neuloží ani neotevře modál |
Postup: přidávám komentáře nebo hodnocení do další editace
- Injektuj továrnu (
CommentThreadFactory,RatingThreadFactory) konstruktorem presenteru nebo editační komponenty. -
V továrně komponenty nastav kontext — callback, který vrátí vlákno právě editované entity:
protected function createComponentCommentThread(): CommentThread { return $this->commentThreadFactory->create() ->setThreadContext(fn(): ?int => $this->editedArticle?->getCommentThreadId()); }Callback se volá až při signálu nebo odeslání, kdy
action*()hostitele entitu už načetl. 3. Do šablony tabu dej{control commentThread $commentThreadId}({control ratingThread $ratingThreadId}); id vlákna předej zrender*(). 4. Ověř práva: komponenty samy hlídají:Admin:Base:Comment:edit/:dela:Admin:Base:Rating:edit/:del, i při ručně sestavené URL.
Bez setThreadContext() komponenta tiše nefunguje
Výpis se vykreslí, ale tlačítka Přidat a Upravit modál neotevřou (signál skončí 403)
a uložení nic nezapíše. Kontext je pojistka proti tomu, aby podvržené threadId
zapsalo komentář nebo hodnocení k cizímu objektu.
Moderace napříč sekcemi. Vedle tabů jsou dva centrální výpisy přes celé tabulky:
CommentPresenter a RatingPresenter (detail vlákna CommentThreadPresenter,
RatingThreadPresenter). Sekce (e-shop, blog…) je uložená na vláknu jako type, ne na řádku
komentáře. Asociaci na vlákno namapovat nejde, protože týž sloupec už mapuje skalár threadId
a Doctrine druhé zapisovatelné mapování odmítne. Výpis proto načte vlákna zvlášť a spáruje je
mapou threadId => type; odkaz z menu modulu je tentýž výpis s předvyplněným type.
Konkrétní článek nebo produkt výpis neukáže: vazbu drží objekt (commentThreadId,
ratingThreadId), ne vlákno.
Mapa sekcí načítá všechna vlákna
Vlákno vzniká ke každému komentovatelnému objektu, takže u velkého katalogu výpis
zpomalí. Pak je potřeba dotahovat typy jen pro threadId na aktuální stránce. Vlákna
bez type (sloupec je nullable) mají sekci prázdnou a filtr sekce je nenajde.
Výpisy článků na frontu#
Krabice článků na hlavní straně a v postranních sloupcích jsou tenké obálky nad
ArticleListProvider. Obálka určí jen řazení (v šabloně jako $orderBy), případně filtr
navíc, a předá je dál; načtení i vykreslení dělá provider.
| Komponenta | Řazení | Filtr navíc | Výchozí styl, počet |
|---|---|---|---|
LastArticleList |
published ↓ |
— | Row, 5 |
MostReadArticleList |
views ↓ |
— | Row, 5 |
MostCommentedArticleList |
commentThread.commentCount ↓, published ↓ |
— | Line, 3 |
TopRatedArticleList |
ratingThread.ratingAvg ↓, ratingThread.ratingCount ↓ |
— | Row, 5 |
MainArticleList |
published ↓ |
main = 1 |
Editorial, 1 |
TaggedArticleList |
id ↓ |
tags.id IN (…) |
Cards, 5; vlastní načítání, ne přes provider |
Počty komentářů a hodnocení se nepočítají agregací: drží je sdílená vlákna
(cms_mod_base_comment_threads.comment_count, cms_mod_base_rating_threads.rating_avg
a rating_count), na která článek ukazuje nullable FK. Řadí se přes asociaci, kterou
JoinHelper připojí LEFT JOINem, takže článek bez vlákna z výpisu nevypadne, jen propadne
na konec. Druhý klíč u hodnocení je záměr: bez něj by článek s jedinou pětkou předběhl
článek se stovkou hodnocení a průměrem 4,9.
Provider k filtrům volajícího vždy přidá activated = 1 a published <= NOW. Přiřazuje je
až po filtrech volajícího pod klíči activated a published, takže je volající nepřebije
ani stejným klíčem. Filtrové objekty se staví v PHP komponenty, ne v Latte (MainArticleList
předá $filters šabloně hotové).
Postup: přidat krabici článků s vlastním řazením#
- Zkopíruj nejbližší obálku (
LastArticleListpro prosté řazení,MainArticleList, když potřebuješ filtr). - V šabloně obálky nastav
$orderBy; řazení přes vlákno piš tečkovou cestou (ratingThread.ratingAvg), ne skalárem. - Filtr navíc postav v
render()a pošli ho jako$filterss klíčem, který se nekříží sactivatedapublished. - Zkontroluj
includev provideru: stylLinevypisuje počet komentářů zcommentThread; bez něj by neměl co vypsat.
Bez označeného hlavního článku je titulní blok prázdný
MainArticleList při nulovém počtu článků s příznakem main vykreslí hlášku „žádné
články“ a nedosadí nejnovější článek. Je to záměr: redakce má poznat, že titulek nikde
nevybrala. Příznak smí mít víc článků, vybírá řazení a limit volajícího.
TaggedArticleList s prázdnými štítky vypíše všechny články
Prázdné $tagIds filtr na štítky vůbec nepřidá a komponenta se chová jako obyčejný výpis
nejnovějších (id ↓). Štítek bez článků naopak dá prázdný výpis s běžnou hláškou.
showOnlyCategoryIds omezí výpis na kategorie
Všechny obálky i ArticleListProvider::render() ho předávají do InitRenderTrait::initRender(),
který z něj postaví filtr categories.id IN (...) (stejný tvar jako tags.id
v TaggedArticleList). Článek stačí mít v jedné z kategorií. Prázdný seznam filtr nepřidá
a výpis ukáže všechny kategorie. Klíč categoryId v $filters tento parametr přepíše.
Galerie na hlavní straně#
Galerii na hlavní stranu kreslí LastGallery: vybere nejnovější aktivní a publikovanou galerii
(nebo zadané $galleryId) a vykreslení předá frontSystemGallery. Galerie s
only_for_logged přeskakuje, i když je zadaná id, protože na veřejné straně by na ni půlka
návštěvníků nedosáhla. Fotky nad $maxAssets sečte do „+ N“.
Karusel#
Karusel z administrace (Blog → Karusely) kreslí na webu komponenta frontBlogCarousel
(app/UI/Front/Blog/Components/CarouselComponent/):
{control frontBlogCarousel 1} {* karusel 1, jeden snímek naráz *}
{control frontBlogCarousel 1, 3} {* tři viditelné snímky *}
| Pravidlo | Jak |
|---|---|
| který karusel | jen aktivní (activated = 1) se zveřejněním prázdným nebo v minulosti |
| snímky | HTML kód snímku v jazyce stránky; snímek bez kódu v tom jazyce se přeskočí, bez snímků se nevykreslí nic |
| kostra | sdílená .list--carousel (jako bleskovky); šipky, tečky a posun dělá tentýž skript, počet viditelných nese --carousel-cols |
| šipky a tečky | jen když je snímků víc než viditelných ($perPage) |
| oprávnění | čtení :Api:Blog:Carousel:getAll má host i přihlášený, stejně jako bleskovky |
Kód snímku se vypisuje bez escapování
Snímek je HTML z administrace a šablona ho tiskne |noescape. Zápis snímků proto nesmí
dostat role mimo administraci; nová cesta zápisu z frontu nebo od externího klienta by
potřebovala purifikaci v Api beforeSave().
Obrázky obsahu#
Obrázky neprocházejí objektovým grafem. Administrace je posílá v $meta a Api manager je
zpracuje v afterSave(): uloží Asset s variantami, zapíše vazbu a obrázky, které z formuláře
vypadly, uvolní (viz Varianty obrázků).
| Entita | Klíče v $meta |
Chybějící klíč |
|---|---|---|
Article, Category, Event, FlashMessage (jeden obrázek) |
image = {name, data} (base64) → nový obrázek; imageDelete = true bez image → cizí klíč asset se vynuluje |
obrázek zůstane |
Gallery |
images = seznam řádků v pořadí galerie; řádek nese image (nový) nebo assetId (ponechaný) a texts[<langId>] = {title, description} |
bez klíče images zůstane galerie, jak je |
Intro (obrázek za každý jazyk) |
imageLanguages = id jazyků formuláře; pro každý image_<langId> (nový), imageDelete_<langId>, imageCurrentId_<langId> (ponechaný) |
bez imageLanguages zůstanou vazby, jak jsou |
Galerie a úvodní panel zapisují vazby přes updateColumn('assetRels', …), tedy úplným
nahrazením. Co v $meta není, z galerie zmizí. Externí klient smí poslat jen images galerie
(vnořené v data, $graphDataKeys); ostatní klíče jsou jen v $meta, které externí cesta
zahazuje.
Postup: vlastní zápis obrázků galerie nebo panelu
- Načti stávající obrázky a pošli je všechny: u galerie řádky s
assetId, u paneluimageCurrentId_<langId>pro každý jazyk zimageLanguages. - U ponechaného obrázku galerie pošli i
texts, jinak přijde o popisky. - Pořadí řádků galerie je pořadí obrázků; prázdný řádek a opakovaný obrázek se přeskočí.
- Ověř v DB vazby (
cms_mod_blog_gallery_image_relations,cms_mod_blog_intro_image_relations) i to, že vyřazené obrázky zmizely zcms_system_images.
Jazyk v imageLanguages bez obrázku přijde o obrázek
Když pro jazyk nepřijde nový obrázek ani imageCurrentId_<langId>, vazba se nezapíše
a obrázek se uvolní, i se soubory. Stejně dopadne obrázek galerie, který v images
chybí.
Popisky obrázku galerie se přepisují celé
Texty se zapíšou smazáním a vložením (updateColumn('translations', …)) u každého řádku,
i u ponechaného. Řádek bez texts popisky ve všech jazycích smaže. Jazyk s prázdným
titulkem i popisem se vynechá.
Naplánované úlohy#
| Endpoint | Co dělá | Doporučený interval |
|---|---|---|
/cron/blog/elastic/sync |
průběžná synchronizace indexu | à 10 minut |
/cron/blog/elastic/reindex |
úplný reindex | denně v noci |
/cron/blog/sitemap/{article,event,news} |
sitemapy obsahu | denně v noci |
/cron/blog/sitemap/{article,event,news}-category |
sitemapy kategorií | denně v noci |
Kam sáhnout#
| Chci | Kde |
|---|---|
| vzor zdroje pro vyhledávání | app/UI/Api/Blog/Models/Search/ArticleDocumentSource.php |
| vzor rozkladu presenteru | app/UI/Front/Blog/Presenters/Traits/ |
| managery obsahu | app/UI/Api/Blog/Models/Managers/ |
| sdílené komentáře a hodnocení | app/UI/Api/Base/Models/Entities/ |
| schválení, brány vložení, odpověď na hodnocení | app/UI/Api/Base/Models/Managers/{Comment,Rating}Manager.php, app/UI/Api/Base/Presenters/RatingPresenter.php |
| hlas o užitečnosti a nahlášení | app/UI/Api/Base/Models/Managers/RatingVoteManager.php |
| cron úlohy | app/UI/Cron/Blog/Presenters/ |
| cíl odkazů kategorií a regionů | app/UI/Front/Blog/Components/Traits/BlogListTargetTrait.php |
| region ve frontu | app/UI/Front/Blog/Presenters/Traits/Inits/RegionTrait.php |
| krabice článků a galerie na hlavní straně | app/UI/Front/Blog/Components/{ArticleListProvider,MainArticleList,TaggedArticleList,LastGallery}Component/ |
Navazující kapitoly: Elasticsearch · Diskuze · Překlady · Konvence kódu