Content Page Management
Overview
Content Management is the module for creating and editing content pages for a LINE OA. Page bodies are stored as HTML authored through a rich text editor (TipTap), and a single record carries both languages side by side (Thai and English) as one set of translations.
The feature is aimed at marketing and content teams that need to publish articles, promotion pages, or service information for users to open via a link or QR code from the LINE OA, without requiring additional web development.
Each content page holds the following data:
| Group | Description |
|---|---|
| Core fields | The slug used to build the URL, and the page status (draft / published / archived) |
| Per-language content | Title, HTML body, excerpt, and the meta/OG tag set for each language |
| Categorization | Primary category and subcategory |
| Access control | Allowed audiences and optional password protection |
| Friend Track | The linked add-friend campaign and its CTA button label |
| Campaign settings | UTM parameters for source tracking and analytics configuration (GA, Facebook Pixel, LINE Tag, custom scripts) |
A content page can be in one of three states:
| Status | Meaning |
|---|---|
| Draft | Saved but not yet visible to users — used for work in progress |
| Published | Reachable through the public link or QR code |
| Archived | No longer reachable, but retained in the system for reference |
Key points to be aware of:
- After saving, the backend issues a
publicTokenfor each page, used to build the public URL served by client-web. - The list table shows total and unique view counters for every page, so page performance can be compared without opening an external analytics tool.
- Audience restriction and Friend Track campaigns are mutually exclusive — configuring one disables the other, since a page already limited to a known audience has no need for an add-friend call to action.
- Publication state is changed through the save actions in the form (save as draft, or publish); there is no separate status toggle on the list page.
- Both language translations live in the same record, so editing a page always edits both languages together rather than maintaining two separate entries.
Business Flow
Browsing the content page list
- Opening
/content-managementloads a paginated list with a search term and a status filter (all / published / draft / archived). - The system reads the current LINE OA to obtain its LIFF ID and OA hash, which are needed to build the public URLs behind the copy-link and QR code actions.
- The copy-link action picks a URL shape based on the page's configuration. If the page is audience-restricted and the LINE OA has a LIFF ID, a LIFF URL is produced (
https://liff.line.me/<liffId>/<lineOaHash>/content/<slug>); otherwise a standard web URL is used (<BASE_APP_URL>/<lineOaHash>/content/<slug>). Pages without a slug fall back to thepublicToken. - The QR action opens a dialog showing the QR code for that same URL, with a PNG download.
- Deletion requires confirmation; on success the table refreshes automatically.
Creating a content page
- The create action opens the multi-language form (
/content-management/create-multilang), a two-column screen with the editor on the left and a live preview on the right. - The preview panel simulates three viewport widths (full-width desktop, 768px tablet, 375px mobile) and follows the language tab currently being edited.
- Content tab — enter the title per language; the slug is generated automatically from the title and can still be overridden. The body is written in the rich text editor, and a collapsible SEO section covers meta title, meta description, excerpt, OG title, OG description, and OG image.
- Choose a category from the primary category list; selecting one loads that category's hierarchical subcategories for further selection.
- Access Control tab — select one or more audiences to restrict viewers, and/or enable password protection with a password.
- Friends Track tab — pick a campaign from the list of active campaigns and set the CTA button label, with a preview of the LINE-styled button.
- Campaign Settings tab — enter UTM values, where source and medium can be chosen from presets or typed freely while campaign, term, and content are free-text fields. Analytics settings cover enabling Google Analytics with its event list, Facebook Pixel events, LINE Tag events, and custom scripts injected into the page head. A preview of the resulting URL is shown for each language.
- Save as draft or publish. The system validates that at least one language has a title and that a slug is present, then creates the record and returns to the list.
Editing a content page
- The edit screen at
/content-management/edittakes the content page ID from the query string. - The existing record is loaded and mapped back into the full form structure: both language translations, category, audiences, Friend Track, password protection, UTM, and analytics.
- Saving works the same way as creation, except that the status is only submitted when the user presses an action that explicitly sets it. A plain save leaves the current publication state untouched.
- Published pages can be edited and saved over at any time; visitors using the existing link see the updated content, and neither the link nor the QR code changes.
Publishing and tracking
- Once a page is published, copy its link or download the QR code from the list and distribute it through the desired channel — a rich menu, a broadcast message, or offline media.
- If UTM values are configured, the generated link carries the tracking parameters automatically, allowing traffic sources to be distinguished in analytics tools.
- When a visitor opens the page, the configured analytics scripts are injected and begin recording the specified events, while the system also records the page view that feeds the counters on the list page.
- If password protection is enabled, the visitor must supply the correct password before the content is shown; if the page is audience-restricted, eligibility is checked against the LINE OA context before rendering.
Key Screens & Components
List page (/content-management)
- Filter bar — a search field and a status selector, alongside pagination.
- Content table — title, slug, category, status, view counts, and last-updated timestamp.
- Copy link and QR actions — build the public URL in the shape appropriate to the page's configuration and render a downloadable QR code.
- Delete action — guarded by an inline confirmation popover.
The system builds public URLs in two shapes:
| Page configuration | Link format |
|---|---|
| Audience-restricted and the LINE OA has a LIFF ID | https://liff.line.me/<liffId>/<lineOaHash>/content/<slug> |
| Not audience-restricted, or no LIFF ID available | <BASE_APP_URL>/<lineOaHash>/content/<slug> |
In both shapes the publicToken takes the place of the slug when a page has none.
Primary file: src/app/content-management/page.tsx
Create page (/content-management/create-multilang) and edit page (/content-management/edit)
- Multi-language form (
src/components/content-management/MultiLanguageForm.tsx) — Thai/English tabs covering title, slug, body, and the collapsible SEO section. - Rich text editor (
src/components/content-management/TiptapEditorSimple.tsx) — the module's main authoring surface, covering:- Basic formatting such as headings, bold, italic, ordered and unordered lists, and blockquotes.
- Links, text alignment, highlighting, line height, and text background colour.
- Direct image upload into the body, via file picker or by dragging a file onto the editing area.
- In-place resizing of inserted images; the saved result is HTML that renders as-is on the public page.
- Content viewer (
src/components/content-management/TiptapViewer.tsx) — renders the stored HTML read-only; used both in the preview panel and on the public page. - Category selector (
src/components/content-management/CategoryCascader.tsx) — a cascader that loads top-level categories first and fetches hierarchical subcategories on expand, supporting up to four levels. - Live preview panel — renders the page at the chosen viewport width in the language currently being edited, making it easy to check the mobile layout before publishing.
The four tabs in the left-hand column divide the configuration as follows:
| Tab | Covers |
|---|---|
| Content | Title, slug, body, and SEO fields per language, plus category selection |
| Access Control | Permitted audiences and password protection |
| Friends Track | The add-friend campaign and its CTA button label |
| Campaign Settings | UTM values and analytics configuration, with a preview of the resulting URL |
API service
All calls live in src/services/content-page.service.ts under the content-pages base path.
| Capability | Endpoint |
|---|---|
| List content pages | GET /content-pages |
| Get a single page | GET /content-pages/{id} |
| Create a page | POST /content-pages |
| Update a page | PATCH /content-pages/{id} |
| Delete a page | DELETE /content-pages/{id} |
| Upload an image into the body | POST /content-pages/upload-image |
The service also exposes additional operations reserved for future use — publish, unpublish, archive, duplicate, standalone UTM and analytics updates, regenerating the publicToken, plus fetching public content and recording views. No screen in the CMS currently calls this group.
Dependencies
- Permissions — the parent menu is gated by the
content-managementmodule permission and the child menu bycontent-page; both are unlocked by the backendline-oamodule. - Content Category / Subcategory — the source of the category and subcategory options in the form.
- Audience — defines who may open the page and determines whether the public link is issued as a LIFF URL or a plain web URL.
- Friend Track — supplies the campaigns bound to the add-friend CTA button on the page.
- LINE OA Management — provides the LIFF ID and OA hash used to assemble public links and QR codes.
- Content Links — aggregates content pages into filtered link lists.
- Menu Builder — offers published content pages as menu targets, referenced by the page token.
- Public pages on client-web — the actual destination users reach from a link or QR code, and where the configured analytics scripts execute.
- Shared infrastructure — the CMS HTTP client (automatic bearer token and sign-out on expiry), the breadcrumb and side-menu system, and the shared translation namespaces used across the CMS.
Backend Details (CMS API)
The backend lives in internal/modules/contentpage/. It splits cleanly into two route groups: the administrative routes, which require a login, and the public routes, which readers reach without one.
Required permissions
- The administrative routes only require the shared authentication check (JWT). They are not wrapped in a module gate, and the declared policy references the
line-oamodule as metadata that is not yet enforced. - As a result, per-menu access control for this feature is enforced mainly on the front end; any signed-in user can still call the content management endpoints directly.
- The public routes carry no guard at all, by design, since general readers must be able to reach them.
What the backend generates, and how pages are protected
- On creation the backend generates the public token using a cryptographically secure 32-byte random value, making it unguessable — which is what allows the public link to be distributed safely.
- New pages always start in draft status; saving never publishes on its own.
- Page passwords are hashed with bcrypt before storage rather than being kept in plain text, and opening a locked page compares against that hash.
- The body image upload endpoint checks the declared content type before accepting the file.
What gets stored, and the side effects
- Data spans several tables:
content_page(the page),content_page_translation(per-language content),content_page_viewandcontent_page_analytics(visit data), andcontent_page_utm(source tracking values), with references intocontent_categoryandcontent_subcategory. - View counting happens through a separate public endpoint that the reader-facing page calls; it is not a side effect of serving the page. That call increments the counter and records an event for the statistics.
- Body images live on object storage, while the saved content itself is directly renderable HTML.
- This module publishes no queue work and writes no cache, so content edits are visible on the public page immediately.
Backend capabilities the UI does not yet call
The API supports more than the current screens use:
POST /api/content-pages/:id/publishandPOST /api/content-pages/:id/archivefor changing status independently of saving the content.POST /api/content-pages/:id/duplicatefor copying a whole page, images included.GET /api/content-pages/:id/analyticsfor per-page visit analytics that go well beyond the counters shown in the list table.
How the public routes behave
GET /api/public/content-pages/:tokenis the route readers use to open a page; locked pages must supply the password as well.- Every failure case collapses into the same "content not found" error — an invalid token, an unpublished page, or a wrong password all look identical. That is good for security, because someone guessing tokens cannot tell whether a page exists.
- Security note — the page password is passed in the query string, where it can end up in server logs, proxy logs, and browser history. Users should not reuse a password they use elsewhere.
- A second, similar public route exists in the public API module with additional LIFF support, so any change to public page behaviour has to account for both paths.
Edge cases worth knowing
- The
content_pagetable uses a plain deletion-timestamp column rather than the ORM's automatic soft-delete mechanism. Every read query has to spell out the "not deleted" condition itself; forgetting it makes deleted pages reappear in results — including on the public route. - The two language translations live in a separate translations table rather than in the page row, so adding a third language later would not require changing the core structure.
- Because the public token is not regenerated when content is edited, links and QR codes already distributed keep working and always point at the latest version.
- The view counts shown in the table depend on the reader-facing page successfully calling the tracking endpoint, so they can under-report when a visitor leaves quickly or the browser blocks the call.