From 64defe7f7412c90f5f936a977bf85c97b3fff8f3 Mon Sep 17 00:00:00 2001 From: code Date: Wed, 15 Jul 2026 19:38:04 +0000 Subject: [PATCH] Add in-house comments (Lib\Comments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reusable comment thread any sidecar can attach to any page, tied to real logged-in accounts (never anonymous), auto-approved on submission with hide/delete moderation at /admin/comments — only a verified account can post, so there's no anonymous-spam vector to pre-vet against. Framework-level (novaconium/lib, novaconium/migrations, novaconium/pages), not App/migrations, matching Admin auth and Media manager's shape. A page needs its own sidecar to use it — this repo has no client-side JS, so comments ride the same server-rendered POST pattern as every other dynamic feature, which is also what excludes a page from the static cache. App/pages/blog/comments-demo/ demonstrates the pattern without touching existing posts that are referenced elsewhere as the sidecar-less example. Co-Authored-By: Claude Sonnet 5 --- App/pages/blog/_layout/layout.twig | 7 ++ App/pages/blog/comments-demo/index.php | 59 +++++++++++++ App/pages/blog/comments-demo/index.twig | 27 ++++++ App/pages/blog/index.php | 6 ++ App/pages/blog/novaconium-features/index.twig | 1 + novaconium/ISSUES.md | 80 +++++++++++------- novaconium/lib/Comments.php | 84 +++++++++++++++++++ .../migrations/0004_create_comments.sql | 9 ++ .../pages/_partials/comments/thread.twig | 45 ++++++++++ novaconium/pages/admin/comments/index.php | 65 ++++++++++++++ novaconium/pages/admin/comments/index.twig | 67 +++++++++++++++ .../pages/admin/docs/_layout/layout.twig | 1 + .../pages/admin/docs/comments/index.twig | 52 ++++++++++++ novaconium/pages/admin/docs/index.twig | 3 +- novaconium/pages/admin/index.twig | 1 + 15 files changed, 478 insertions(+), 29 deletions(-) create mode 100644 App/pages/blog/comments-demo/index.php create mode 100644 App/pages/blog/comments-demo/index.twig create mode 100644 novaconium/lib/Comments.php create mode 100644 novaconium/migrations/0004_create_comments.sql create mode 100644 novaconium/pages/_partials/comments/thread.twig create mode 100644 novaconium/pages/admin/comments/index.php create mode 100644 novaconium/pages/admin/comments/index.twig create mode 100644 novaconium/pages/admin/docs/comments/index.twig diff --git a/App/pages/blog/_layout/layout.twig b/App/pages/blog/_layout/layout.twig index 2fb2e22..ebf9a17 100644 --- a/App/pages/blog/_layout/layout.twig +++ b/App/pages/blog/_layout/layout.twig @@ -17,6 +17,13 @@
{% block blog_content %}{% endblock %} + {# Only pages whose sidecar opts in by returning a 'comments' + key get a thread — see /admin/docs/comments and + App/pages/blog/hello-world/index.php. A sidecar-less post + never has this key, so it's silently skipped. #} + {% if comments is defined %} + {% include '_partials/comments/thread.twig' %} + {% endif %}
{% endblock %} diff --git a/App/pages/blog/comments-demo/index.php b/App/pages/blog/comments-demo/index.php new file mode 100644 index 0000000..af0aecc --- /dev/null +++ b/App/pages/blog/comments-demo/index.php @@ -0,0 +1,59 @@ +required($body, 'body', 'Enter a comment.') + ->maxLength($body, 'body', 2000, 'Comments are 2000 characters max.'); + + if ($validator->passes()) { + // Same "bot gets an identical response" reasoning as the contact + // form — see App/pages/contact/index.php. Only the insert is + // skipped for spam. + if (!$spamGuard->isSpam(Input::post())) { + Comments::create($pagePath, $user['id'], $body); + } + + return Response::redirect($pagePath); + } + + $commentError = $validator->errors()['body'] ?? null; +} + +return [ + 'comments' => Comments::forPage($pagePath), + 'currentUser' => $user, + 'commentError' => $commentError, + 'renderedAt' => $spamGuard->renderedAt(), + 'csrfField' => Csrf::fieldName(), + 'csrfToken' => Csrf::token(), +]; diff --git a/App/pages/blog/comments-demo/index.twig b/App/pages/blog/comments-demo/index.twig new file mode 100644 index 0000000..8a2d3c2 --- /dev/null +++ b/App/pages/blog/comments-demo/index.twig @@ -0,0 +1,27 @@ +{% extends layout %} + +{% import '_layout/icons.twig' as icons %} + +{% block title %}Comments Demo{% endblock %} +{% block description %}A worked example of Lib\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.{% endblock %} + +{% block robots %}index, follow{% endblock %} +{% block tags %}comments, meta{% endblock %} +{% block canonical %}{{ request_path|default('/') }}{% endblock %} + +{% block og_type %}article{% endblock %} +{% block og_title %}{{ block('title') }}{% endblock %} +{% block og_description %}{{ block('description') }}{% endblock %} +{% block og_url %}{{ block('canonical') }}{% endblock %} + +{% block twitter_card %}summary{% endblock %} +{% block twitter_title %}{{ block('title') }}{% endblock %} +{% block twitter_description %}{{ block('description') }}{% endblock %} + +{% block blog_content %} +

Comments Demo

+ +

Unlike every other post under App/pages/blog/, this one has its own index.php sidecar — App/pages/blog/comments-demo/index.php — which reads and writes a comments table via Lib\Comments and returns a comments key in its context. App/pages/blog/_layout/layout.twig only includes the comment-thread partial (novaconium/pages/_partials/comments/thread.twig) when that key is present, so this is the only post here with a thread below.

+ +

Having a sidecar means this page is never served from the static HTML cache the way its sidecar-less siblings are (see {{ icons.book() }}Static caching) — an explicit, per-page tradeoff you accept the moment a page needs comments. See {{ icons.users() }}Comments for the full write-up of Lib\Comments, including why comments are tied to real logged-in accounts rather than anonymous name/email fields, and why they're auto-approved with after-the-fact moderation at /admin/comments rather than a pending queue.

+{% endblock %} diff --git a/App/pages/blog/index.php b/App/pages/blog/index.php index 1294796..56f76c9 100644 --- a/App/pages/blog/index.php +++ b/App/pages/blog/index.php @@ -46,5 +46,11 @@ return [ 'excerpt' => 'A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.', 'published' => '2026-07-15', ], + [ + 'slug' => 'comments-demo', + 'title' => 'Comments Demo', + 'excerpt' => 'A worked example of Lib\\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.', + 'published' => '2026-07-15', + ], ], ]; diff --git a/App/pages/blog/novaconium-features/index.twig b/App/pages/blog/novaconium-features/index.twig index 02194af..fb941b6 100644 --- a/App/pages/blog/novaconium-features/index.twig +++ b/App/pages/blog/novaconium-features/index.twig @@ -36,6 +36,7 @@
  • Access control — assign a page (or a section, one line per page) to a user or group from its sidecar: Access::require('group:members') returns null or a ready-made Response (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction.
  • Draft pages — list a route under draft_routes in App/config.php to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt.
  • Media manager/admin/media, an upload/browse/delete UI for files under public/uploads/, covered by the existing /admin/* auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.
  • +
  • CommentsLib\Comments, a reusable comment thread any page can attach to itself via its own sidecar (see App/pages/blog/comments-demo/). Tied to real logged-in accounts, not anonymous name/email fields; auto-approved on submission with after-the-fact hide/delete moderation at /admin/comments.
  • Dark/light theme toggle — a nav button flips a data-theme attribute (persisted to localStorage) that swaps every color via CSS custom properties.
  • Self-hosted spam prevention & form validationLib\SpamGuard (honeypot + submission-timing check, no external CAPTCHA), Lib\FormValidator, and Lib\Validate, demonstrated on the contact form.
  • Form security by defaultLib\Input (cleaning accessor for $_POST/$_GET) and Lib\Csrf (standalone session-token CSRF protection), wired into the contact form and every admin form.
  • diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index 79bf02c..eb9fd4d 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -78,34 +78,6 @@ Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager. See **Won't Do** below for 404 tracking, dropped in favor of Matomo. -### In-house comments - -- **Type:** Feature -- **Status:** Backlog -- **Priority:** Medium -- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done) -- **Added:** 2026-07-14 - -A self-hosted comments library — no third-party service (Disqus, -Commento, etc.) — a `Lib\` class any sidecar can call to attach comments -to any page, not just blog posts, the same way `Lib\SpamGuard`/ -`Lib\FormValidator` are reusable across any form rather than hardcoded to -the contact page. Comments tied to a real user account rather than -anonymous name/email fields, which is why this rides on Admin login & -user management rather than SQLite groundwork alone — needs that -feature's user store to exist first. Likely a `comments` table -(route/user/body/created_at/approved or similar — a migration under -`App/migrations/`, following the two-root convention documented in -`/admin/docs/database`) plus a small set of sidecar-callable methods -(list comments for a route, submit one, moderate one). Needs a decision -on moderation model (auto-approve vs. admin-approval queue, reusing the -`/admin/*` auth gate for the moderation UI) and on spam handling (reuse -`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second -mechanism, consistent with how CSRF protection already works — see -`/admin/docs/sidecars`'s "Form security" section for the existing -input-cleaning/CSRF/spam-prevention layers a comment form should compose -the same way the contact form does). - ### Ecommerce functionality - **Type:** Feature @@ -158,6 +130,58 @@ _Nothing yet._ ## Done +### In-house comments + +- **Type:** Feature +- **Status:** Done +- **Priority:** Medium +- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done) +- **Added:** 2026-07-14 +- **Shipped:** 2026-07-15 + +A self-hosted comment thread — no third-party service (Disqus, Commento, +etc.) — `Lib\Comments` (`novaconium/lib/Comments.php`), a plain static +class any sidecar can call to attach comments to any page, not just blog +posts, the same way `Lib\SpamGuard`/`Lib\FormValidator` are reusable +across any form rather than hardcoded to the contact page. Comments are +tied to a real logged-in account, never anonymous name/email fields — +`App\AdminAuth::currentUser()` already excludes disabled and unverified +accounts (see Email verification below), so there's nothing further to +check before accepting a comment from whoever it returns. + +**Framework-level, not `App/migrations/`.** The original backlog draft +sketched the migration under `App/migrations/`, but comments — like Admin +auth and Media manager — is reusable framework functionality, not project +content, so the migration (`novaconium/migrations/0004_create_comments.sql`), +the class, and the moderation UI (`novaconium/pages/admin/comments/`) all +live under `novaconium/`, consistent with every other feature of this +shape. + +**Moderation model: auto-approve, then moderate.** A comment is visible +the instant it's posted — no pending queue — since only a verified +account can post one at all, there's no anonymous-spam vector to pre-vet +against. An admin can hide (excluded at the query level, not just +visually) or permanently delete any comment after the fact at +`/admin/comments`, mirroring how `/admin/users` disables rather than +pre-vets accounts. Submission still runs through `Lib\SpamGuard`'s +honeypot/timing check and `Lib\Csrf`, same as the contact form. + +**A page needs its own sidecar to use it.** This codebase has no +client-side JS/fetch anywhere — every dynamic feature is a plain +server-rendered POST form — so a comment thread is no different: giving a +page a sidecar is what excludes it from the static HTML cache (only +sidecar-less pages are ever cached), an explicit per-page tradeoff the +same as `/admin/media` and `/search`. `App/pages/blog/comments-demo/` is +the one post under `App/pages/blog/` with a sidecar, added specifically +to demonstrate the pattern without disturbing the other posts (several of +which reference `hello-world` by name as the canonical *sidecar-less* +example — retrofitting it instead would have made those references +wrong). The reusable Twig half is +`novaconium/pages/_partials/comments/thread.twig` — a `_`-prefixed, +never-routable partial, included with `{% if comments is defined %}` so +a layout shared by comment-enabled and sidecar-less pages alike can +include it unconditionally. Documented at `/admin/docs/comments`. + ### Email verification for user accounts - **Type:** Feature diff --git a/novaconium/lib/Comments.php b/novaconium/lib/Comments.php new file mode 100644 index 0000000..f9e6138 --- /dev/null +++ b/novaconium/lib/Comments.php @@ -0,0 +1,84 @@ +> + */ + public static function forPage(string $pagePath): array + { + return Db::query( + 'SELECT comments.id, comments.body, comments.created_at, users.username ' . + 'FROM comments JOIN users ON users.id = comments.user_id ' . + 'WHERE comments.page_path = ? AND comments.is_hidden = 0 ' . + 'ORDER BY comments.created_at ASC', + [$pagePath] + )->fetchAll(\PDO::FETCH_ASSOC); + } + + public static function create(string $pagePath, int $userId, string $body): void + { + Db::query( + 'INSERT INTO comments (page_path, user_id, body, is_hidden, created_at) VALUES (?, ?, ?, 0, ?)', + [$pagePath, $userId, $body, gmdate('Y-m-d\TH:i:s\Z')] + ); + } + + public static function setHidden(int $id, bool $hidden): void + { + Db::query('UPDATE comments SET is_hidden = ? WHERE id = ?', [$hidden ? 1 : 0, $id]); + } + + public static function delete(int $id): void + { + Db::query('DELETE FROM comments WHERE id = ?', [$id]); + } + + /** + * Every comment, hidden or not, newest first — for the /admin/comments + * moderation list. + * + * @return array> + */ + public static function all(): array + { + return Db::query( + 'SELECT comments.id, comments.page_path, comments.body, comments.created_at, comments.is_hidden, users.username ' . + 'FROM comments JOIN users ON users.id = comments.user_id ' . + 'ORDER BY comments.created_at DESC' + )->fetchAll(\PDO::FETCH_ASSOC); + } +} diff --git a/novaconium/migrations/0004_create_comments.sql b/novaconium/migrations/0004_create_comments.sql new file mode 100644 index 0000000..53df21a --- /dev/null +++ b/novaconium/migrations/0004_create_comments.sql @@ -0,0 +1,9 @@ +CREATE TABLE comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_path TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + is_hidden INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +CREATE INDEX idx_comments_page_path ON comments(page_path); diff --git a/novaconium/pages/_partials/comments/thread.twig b/novaconium/pages/_partials/comments/thread.twig new file mode 100644 index 0000000..431fcff --- /dev/null +++ b/novaconium/pages/_partials/comments/thread.twig @@ -0,0 +1,45 @@ +{# + Reusable comment thread — included from any page whose sidecar + returns 'comments' (Lib\Comments::forPage()), 'currentUser' + (App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and + 'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See + /admin/docs/comments for the full sidecar contract this expects, and + App/pages/blog/hello-world/index.php for a worked example. A page + with no 'comments' key never includes this at all — see the + surrounding {% if comments is defined %} in blog/_layout/layout.twig. +#} +{% import '_layout/icons.twig' as icons %} + +
    +

    {{ icons.users() }}Comments

    + + {% if comments is empty %} +

    No comments yet.

    + {% else %} + {% for comment in comments %} +
    +

    {{ comment.username }}{{ comment.created_at }}

    +

    {{ comment.body }}

    +
    + {% endfor %} + {% endif %} + + {% if currentUser %} +
    + + + +

    +
    + + {% if commentError %}
    {{ commentError }}{% endif %} +

    + +
    + {% else %} +

    Log in to leave a comment.

    + {% endif %} +
    diff --git a/novaconium/pages/admin/comments/index.php b/novaconium/pages/admin/comments/index.php new file mode 100644 index 0000000..20f77e0 --- /dev/null +++ b/novaconium/pages/admin/comments/index.php @@ -0,0 +1,65 @@ +fetch(PDO::FETCH_ASSOC); + + if ($target === false) { + Session::flash('comments_error', 'No such comment.'); + } elseif ($action === 'hide') { + Comments::setHidden($id, true); + Session::flash('comments_notice', 'Comment hidden.'); + } elseif ($action === 'show') { + Comments::setHidden($id, false); + Session::flash('comments_notice', 'Comment shown.'); + } elseif ($action === 'delete') { + Comments::delete($id); + Session::flash('comments_notice', 'Comment deleted.'); + } + + return Response::redirect('/admin/comments', 303); +} + +// Truncated in PHP, not with Twig's |slice — that filter calls +// mb_substr() unconditionally, a hard mbstring dependency this project +// deliberately avoids (see AGENTS.md). +$truncate = static function (string $body): string { + $limit = 140; + $length = function_exists('mb_strlen') ? mb_strlen($body) : strlen($body); + if ($length <= $limit) { + return $body; + } + + return (function_exists('mb_substr') ? mb_substr($body, 0, $limit) : substr($body, 0, $limit)) . '…'; +}; + +$comments = Comments::all(); +foreach ($comments as &$comment) { + $comment['excerpt'] = $truncate($comment['body']); +} +unset($comment); + +return [ + 'comments' => $comments, + 'notice' => Session::getFlash('comments_notice'), + 'error' => Session::getFlash('comments_error'), + 'csrfField' => Csrf::fieldName(), + 'csrfToken' => Csrf::token(), +]; diff --git a/novaconium/pages/admin/comments/index.twig b/novaconium/pages/admin/comments/index.twig new file mode 100644 index 0000000..4a8c61a --- /dev/null +++ b/novaconium/pages/admin/comments/index.twig @@ -0,0 +1,67 @@ +{% extends layout %} + +{% import '_layout/icons.twig' as icons %} + +{% block title %}Comments{% endblock %} + +{% block description %}Moderate comments left across the site.{% endblock %} + +{% block robots %}noindex, nofollow{% endblock %} + +{% block content %} +
    +

    {{ icons.users() }}Comments

    + +

    Every comment left via Lib\Comments, across every page — auto-approved on submission (only a verified account can post one), moderated here after the fact instead of a pending queue. Hiding a comment removes it from its page immediately; deleting it is permanent. See {{ icons.book() }}Comments.

    + + {% if notice %} +

    {{ notice }}

    + {% endif %} + + {% if error %} +

    {{ error }}

    + {% endif %} + + {% if comments is empty %} +

    No comments yet.

    + {% else %} + + + + + + + + + + + + + {% for comment in comments %} + + + + + + + + + {% endfor %} + +
    PageUserCommentPostedStatusActions
    {{ comment.page_path }}{{ comment.username }}{{ comment.excerpt }}{{ comment.created_at }}{{ comment.is_hidden ? 'Hidden' : 'Visible' }} +
    + + + + +
    +
    + + + + +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/novaconium/pages/admin/docs/_layout/layout.twig b/novaconium/pages/admin/docs/_layout/layout.twig index b33d444..8a6cae8 100644 --- a/novaconium/pages/admin/docs/_layout/layout.twig +++ b/novaconium/pages/admin/docs/_layout/layout.twig @@ -23,6 +23,7 @@
  • {{ icons.lock() }}Access control
  • {{ icons.lock() }}Draft pages
  • {{ icons.tag() }}Media manager
  • +
  • {{ icons.users() }}Comments
  • {{ icons.book() }}Layouts
  • {{ icons.book() }}Static caching
  • {{ icons.book() }}SEO
  • diff --git a/novaconium/pages/admin/docs/comments/index.twig b/novaconium/pages/admin/docs/comments/index.twig new file mode 100644 index 0000000..176542b --- /dev/null +++ b/novaconium/pages/admin/docs/comments/index.twig @@ -0,0 +1,52 @@ +{% extends 'admin/docs/_layout/layout.twig' %} + +{% import '_layout/icons.twig' as icons %} + +{% block title %}Comments{% endblock %} + +{% block description %}Lib\Comments — a reusable comment thread any page can attach to itself via its own sidecar.{% endblock %} + +{% block robots %}noindex, nofollow{% endblock %} + +{% block docs_content %} +

    Comments

    + +

    Lib\Comments is a self-hosted comment thread — no third-party service (Disqus, Commento, etc.) — a plain static class any sidecar can call to attach comments to any page, not just blog posts, the same way {{ icons.email() }}Lib\SpamGuard/Lib\FormValidator are reusable across any form rather than hardcoded to the contact page. It depends on {{ icons.lock() }}admin authentication being enabled — comments are tied to a real logged-in account (App\AdminAuth::currentUser()), never anonymous name/email fields, since currentUser() already excludes disabled and unverified accounts, there's nothing further to check before accepting a comment from whoever it returns.

    + +

    Auto-approve, moderate after

    + +

    A comment is visible the instant it's posted — there's no pending-approval queue. Since only a verified account can post one at all, there's no anonymous-spam vector to pre-vet against, the same reasoning {{ icons.lock() }}admin authentication already applies by disabling rather than pre-vetting accounts. An admin can hide or permanently delete any comment after the fact at /admin/comments — hiding removes it from its page immediately (it's excluded at the query level, not just visually), deleting is permanent.

    + +

    Attaching a thread to a page

    + +

    A page needs its own sidecar to use Lib\Comments — this codebase has no client-side JS/fetch anywhere, every dynamic feature is a plain server-rendered POST form, and comments are no different. Giving a page a sidecar is exactly what excludes it from the {{ icons.book() }}static HTML cache (only sidecar-less pages are ever cached), so attaching comments to a previously sidecar-less page is an explicit, per-page tradeoff — the same one /admin/media and /search already accept. See App/pages/blog/comments-demo/index.php for a full worked example (the one post under App/pages/blog/ with a sidecar, specifically for this):

    + +
    $pagePath = Comments::currentPagePath();
    +$user = AdminAuth::currentUser();
    +
    +if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    +    if (!Csrf::verify(Input::post('csrf_token'))) { ... }
    +    if ($user === null) { ... }               // must be logged in
    +    // validate $body, run it past SpamGuard, then:
    +    Comments::create($pagePath, $user['id'], $body);
    +    return Response::redirect($pagePath);
    +}
    +
    +return [
    +    'comments'    => Comments::forPage($pagePath),
    +    'currentUser' => $user,
    +    // ...csrfField/csrfToken/renderedAt, same as any other form sidecar
    +];
    + +

    Then include the reusable partial, novaconium/pages/_partials/comments/thread.twig — a _-prefixed segment, so it's never itself routable (see {{ icons.link() }}Routing's reserved-segments rule), the same convention _layout/ already uses:

    + +
    {% verbatim %}{% if comments is defined %}
    +    {% include '_partials/comments/thread.twig' %}
    +{% endif %}{% endverbatim %}
    + +

    Guarding the include with comments is defined — as App/pages/blog/_layout/layout.twig does — means a layout shared by both comment-enabled and sidecar-less pages can include it unconditionally without breaking the pages that never set the key. The partial itself expects comments, currentUser, csrfField/csrfToken, and renderedAt in context — the same shape returned above — and renders the thread plus a honeypot-carrying submit form (mirroring the contact form's spam prevention, see {{ icons.email() }}Forms) when someone's logged in, or a "log in to comment" link otherwise.

    + +

    Schema

    + +

    Ships as a framework migration, novaconium/migrations/0004_create_comments.sql — a comments table (id, page_path, user_id, body, is_hidden, created_at) on the same default connection as users, applied automatically the first time anything touches it, no manual step. page_path is whatever Comments::currentPagePath() derives from the request (e.g. /blog/comments-demo) — free-text, not a foreign key, so a thread survives even if the page it was attached to is later restructured.

    +{% endblock %} diff --git a/novaconium/pages/admin/docs/index.twig b/novaconium/pages/admin/docs/index.twig index b6ba560..651fa0e 100644 --- a/novaconium/pages/admin/docs/index.twig +++ b/novaconium/pages/admin/docs/index.twig @@ -4,7 +4,7 @@ {% block title %}Docs{% endblock %} -{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, layouts, caching, styling.{% endblock %} +{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, comments, layouts, caching, styling.{% endblock %} {% block robots %}noindex, nofollow{% endblock %} @@ -43,6 +43,7 @@
  • {{ icons.lock() }}Access control — assign a page or section to a user or group from its sidecar with Lib\Access; public by default, static pages always public.
  • {{ icons.lock() }}Draft pages — let an admin preview a page before the public can see it, reusing the same auth check.
  • {{ icons.tag() }}Media manager — upload, browse, and delete files under public/uploads/ from /admin/media.
  • +
  • {{ icons.users() }}CommentsLib\Comments, a reusable comment thread any page can attach to itself via its own sidecar.
  • {{ icons.book() }}Layouts — pages and layouts are overridable, just like Lib\.
  • {{ icons.book() }}Static caching — how sidecar-less pages get served as static HTML.
  • {{ icons.book() }}SEO — the meta tags every page gets for free, and how to override them.
  • diff --git a/novaconium/pages/admin/index.twig b/novaconium/pages/admin/index.twig index fd5aa64..e53b3d9 100644 --- a/novaconium/pages/admin/index.twig +++ b/novaconium/pages/admin/index.twig @@ -16,6 +16,7 @@
  • {{ icons.book() }}Project docs
  • {{ icons.tag() }}Media
  • {% if admin_auth_enabled %}
  • {{ icons.users() }}Users
  • {% endif %} + {% if admin_auth_enabled %}
  • {{ icons.users() }}Comments
  • {% endif %} {% if admin_auth_enabled %}
  • {{ icons.external_link() }}Logout
  • {% endif %}