Skip to content

Commit f8da133

Browse files
committed
feat(custom-pages): added translations to custom pages and documentation, closes #3015
1 parent 152958b commit f8da133

File tree

19 files changed

+821
-40
lines changed

19 files changed

+821
-40
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
1313
- added API for glossary definitions (Thorsten)
1414
- added admin log CSV export feature (Thorsten)
1515
- added pagination, sorting, and filtering for APIs (Thorsten)
16-
- added support for custom pages with WYSIWYG editor, SEO features, and multi-language support (Thorsten)
16+
- added support for custom pages with WYSIWYG editor, SEO features, multi-language support, search integration (database, Elasticsearch, OpenSearch), sitemap integration, and legal pages support (privacy, terms, imprint, cookies) (Thorsten)
1717
- improved audit and activity log with comprehensive security event tracking (Thorsten)
1818
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
1919
- migrated codebase to use PHP 8.4 language features (Thorsten)

docs/administration.md

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,216 @@ You can delete them, too.
212212

213213
You can edit existing tags, and if you need to, you can delete the tag.
214214

215+
### 5.2.10 Custom Pages Administration
216+
217+
Custom Pages allow you to create database-backed, SEO-friendly pages for legal information, about pages, and other
218+
static content using a WYSIWYG editor. Custom pages support multi-language content, are automatically included in
219+
sitemaps, and are searchable alongside FAQs.
220+
221+
#### 5.2.10.1 Creating a Custom Page
222+
223+
To create a new custom page:
224+
225+
1. Navigate to **Content → Custom Pages** in the admin menu
226+
2. Click the **Add new page** button
227+
3. Fill in the required information across three tabs:
228+
229+
**Content Tab:**
230+
- **Page Title**: The main title of your page (e.g., "Privacy Policy")
231+
- **URL Slug**: SEO-friendly URL identifier (e.g., "privacy-policy")
232+
- Automatically generated from the title
233+
- Must be unique per language
234+
- Real-time validation shows availability
235+
- Results in URL: `https://example.com/page/privacy-policy.html`
236+
- **Content**: Rich text editor (TinyMCE) for page content
237+
- Full WYSIWYG editing with formatting, images, links
238+
- No HTML knowledge required
239+
- Images can be uploaded and managed
240+
241+
**SEO Tab:**
242+
- **SEO Title**: Custom title for search engines (max 60 characters)
243+
- Character counter helps optimize length
244+
- Falls back to the page title if empty
245+
- **Meta-Description**: Description for search results (max 160 characters)
246+
- Character counter helps optimize length
247+
- **Robots Directive**: Controls search engine indexing
248+
- `index, follow` (default): Allow indexing and following links
249+
- `noindex, follow`: Don't index but follow links
250+
- `index, nofollow`: Index but don't follow links
251+
- `noindex, nofollow`: Don't index and don't follow links
252+
253+
**Settings Tab:**
254+
- **Language**: Select the page language (supports multi-language content)
255+
- **Author Name**: Name of the content author
256+
- **Author Email**: Email of the content author
257+
- **Active**: Toggle to publish/unpublish the page
258+
- Only active pages appear in search results and sitemaps
259+
- Inactive pages return 404 errors when accessed
260+
261+
4. Click **Save** to create the page
262+
263+
#### 5.2.10.2 Managing Custom Pages
264+
265+
The Custom Pages list view provides:
266+
267+
- **Pagination**: Navigate through pages with configurable items per page
268+
- **Sorting**: Click column headers to sort by title, slug, language, or date
269+
- **Filtering**: Filter by language or active status
270+
- **Actions**:
271+
- **Edit**: Modify existing pages
272+
- **Delete**: Remove pages (with confirmation)
273+
- **Active Toggle**: Quickly publish/unpublish pages
274+
275+
#### 5.2.10.3 Multi-Language Support
276+
277+
Custom pages support multiple languages:
278+
279+
- Create the same page in different languages using the same ID
280+
- Each language version has its own slug
281+
- Language is selected from the Settings tab
282+
- Example: "Privacy Policy" can exist as:
283+
- English: `privacy-policy`
284+
- German: `datenschutz`
285+
- French: `politique-de-confidentialite`
286+
287+
#### 5.2.10.4 Legal Pages Integration
288+
289+
Custom pages can be used for legal pages with automatic footer link integration:
290+
291+
**Configuration Setup:**
292+
293+
Navigate to **Configuration → Main Settings** and configure:
294+
295+
- **Privacy URL** (`main.privacyURL`)
296+
- **Terms of Service URL** (`main.termsURL`)
297+
- **Imprint URL** (`main.imprintURL`)
298+
- **Cookie Policy URL** (`main.cookiePolicyURL`)
299+
300+
**Two Configuration Options:**
301+
302+
1. **Custom Page Reference**: Use format `page:slug`
303+
- Example: `page:privacy-policy`
304+
- Redirects `/privacy.html``/page/privacy-policy.html`
305+
- Recommended for database-backed legal pages
306+
307+
2. **External URL**: Use full URL
308+
- Example: `https://example.com/legal/privacy`
309+
- Redirects to external website
310+
- Useful if legal pages are hosted elsewhere
311+
312+
**Footer Links:**
313+
314+
When configured, links automatically appear in the footer:
315+
- Privacy Statement (if `main.privacyURL` is set)
316+
- Terms of Service (if `main.termsURL` is set)
317+
- Imprint (if `main.imprintURL` is set)
318+
- Cookie Policy (if `main.cookiePolicyURL` is set)
319+
320+
**Example Configuration:**
321+
322+
```
323+
main.privacyURL = page:privacy-policy
324+
main.termsURL = page:terms-of-service
325+
main.imprintURL = page:imprint
326+
main.cookiePolicyURL = page:cookie-policy
327+
```
328+
329+
#### 5.2.10.5 Search Integration
330+
331+
Custom pages are automatically integrated with all search engines:
332+
333+
**Database Search:**
334+
- Uses LIKE queries on title and content
335+
- Works without additional configuration
336+
- Searches alongside FAQs
337+
338+
**Elasticsearch Integration:**
339+
- Automatically indexed with `content_type='page'`
340+
- Updated in real-time on create/update/delete
341+
- Priority 0.80 in search results
342+
- Bulk import via **Elasticsearch → Import Data**
343+
344+
**OpenSearch Integration:**
345+
- Same functionality as Elasticsearch
346+
- Automatic real-time indexing
347+
- Bulk import via **OpenSearch → Import Data**
348+
349+
**Search Results:**
350+
- Custom pages appear with a file-text icon (📄)
351+
- FAQs appear with a question-circle icon (❓)
352+
- Results link directly to `/page/{slug}.html`
353+
354+
#### 5.2.10.6 Sitemap Integration
355+
356+
Active custom pages are automatically included in XML sitemaps:
357+
358+
- Only active pages (`active='y'`) are included
359+
- Priority: 0.80 (FAQs have 1.00)
360+
- Last modified date from `updated` or `created` field
361+
- URL format: `https://example.com/page/{slug}.html`
362+
- No manual configuration needed
363+
364+
Access sitemap at:
365+
- `https://example.com/sitemap.xml`
366+
- `https://example.com/sitemap.xml.gz` (gzipped)
367+
368+
#### 5.2.10.7 SEO Best Practices
369+
370+
**Slug Guidelines:**
371+
- Use lowercase letters, numbers, and hyphens only
372+
- Keep short and descriptive (e.g., `about-us`, `privacy-policy`)
373+
- Avoid special characters or spaces
374+
- Make it meaningful for users and search engines
375+
376+
**Content Guidelines:**
377+
- Write clear, concise content focused on user needs
378+
- Use headings (H2, H3) to structure content
379+
- Keep paragraphs short for readability
380+
- Include relevant keywords naturally
381+
- Update regularly to keep content current
382+
383+
**SEO Optimization:**
384+
- Fill in SEO title (under 60 characters)
385+
- Write compelling meta description (under 160 characters)
386+
- Use appropriate robots directive
387+
- Set pages as active when ready to publish
388+
- Use descriptive page titles
389+
390+
#### 5.2.10.8 Permissions
391+
392+
Custom pages require the following permissions:
393+
394+
- **PAGE_ADD**: Create new custom pages
395+
- **PAGE_EDIT**: Modify existing pages
396+
- **PAGE_DELETE**: Delete pages
397+
398+
Permissions are granted via **User Administration****Edit User****Permissions**.
399+
400+
Super admins have all permissions by default.
401+
402+
#### 5.2.10.9 Troubleshooting
403+
404+
**Slug validation fails:**
405+
- Ensure slug is unique per language
406+
- Check for special characters (only lowercase, numbers, hyphens allowed)
407+
- Try a different slug
408+
409+
**Page not appearing in search:**
410+
- Verify page is set to active
411+
- Re-index search engine (Elasticsearch/OpenSearch → Drop Index → Create Index → Import Data)
412+
- Check search configuration is enabled
413+
414+
**404 error when accessing the page:**
415+
- Verify the page is active
416+
- Check slug matches URL exactly
417+
- Ensure language matches current site language
418+
419+
**Footer links do not appear:**
420+
- Verify configuration values are set correctly
421+
- Use format `page:slug` or full URL
422+
- Check page exists and is active
423+
- Clear cache if using caching
424+
215425
## 5.3 Statistics
216426

217427
### 5.3.1 Ratings

phpmyfaq/admin/assets/src/content/pages.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,100 @@ export const handleAddPage = (): void => {
186186
}
187187
};
188188

189+
/**
190+
* Handle translate page form
191+
*/
192+
export const handleTranslatePage = (): void => {
193+
const form = document.getElementById('pmf-translate-page-form') as HTMLFormElement | null;
194+
if (!form) return;
195+
196+
// Initialize WYSIWYG editor for content field
197+
renderPageEditor();
198+
199+
const titleInput = document.getElementById('pageTitle') as HTMLInputElement | null;
200+
const slugInput = document.getElementById('slug') as HTMLInputElement | null;
201+
const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value;
202+
const langInput = document.getElementById('lang') as HTMLSelectElement | null;
203+
const pageIdInput = document.getElementById('pageId') as HTMLInputElement | null;
204+
205+
// Auto-generate slug from title
206+
if (titleInput && slugInput) {
207+
titleInput.addEventListener('input', () => {
208+
if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') {
209+
slugInput.value = generateSlug(titleInput.value);
210+
slugInput.dataset.autoGenerated = 'true';
211+
}
212+
});
213+
214+
slugInput.addEventListener('input', () => {
215+
slugInput.dataset.autoGenerated = 'false';
216+
});
217+
}
218+
219+
// Real-time slug validation
220+
if (slugInput && langInput) {
221+
const validateSlugDebounced = debounce(async () => {
222+
const slug = slugInput.value;
223+
const lang = langInput.value;
224+
const feedback = document.getElementById('slug-feedback') as HTMLElement | null;
225+
226+
if (!slug || !lang) return;
227+
228+
const isAvailable = await validateSlug(slug, lang, csrfToken);
229+
230+
if (isAvailable) {
231+
slugInput.classList.remove('is-invalid');
232+
slugInput.classList.add('is-valid');
233+
if (feedback) feedback.textContent = '';
234+
} else {
235+
slugInput.classList.remove('is-valid');
236+
slugInput.classList.add('is-invalid');
237+
if (feedback) feedback.textContent = 'This slug already exists for this language';
238+
}
239+
}, 500);
240+
241+
slugInput.addEventListener('input', validateSlugDebounced);
242+
langInput.addEventListener('change', validateSlugDebounced);
243+
}
244+
245+
// SEO character counters
246+
updateCharCounter('seoTitle', 'seo-title-counter', 60);
247+
updateCharCounter('seoDescription', 'seo-description-counter', 160);
248+
249+
// Form submission
250+
const submitButton = document.getElementById('pmf-submit-page') as HTMLButtonElement | null;
251+
if (submitButton) {
252+
submitButton.addEventListener('click', async (event: Event) => {
253+
event.preventDefault();
254+
255+
const data: PageData = {
256+
pageId: pageIdInput?.value, // Include pageId for translation
257+
pageTitle: (document.getElementById('pageTitle') as HTMLInputElement).value,
258+
slug: (document.getElementById('slug') as HTMLInputElement).value,
259+
content: (document.getElementById('content') as HTMLTextAreaElement).value,
260+
authorName: (document.getElementById('authorName') as HTMLInputElement).value,
261+
authorEmail: (document.getElementById('authorEmail') as HTMLInputElement).value,
262+
active: (document.getElementById('active') as HTMLInputElement).checked,
263+
lang: (document.getElementById('lang') as HTMLSelectElement).value,
264+
seoTitle: (document.getElementById('seoTitle') as HTMLInputElement).value,
265+
seoDescription: (document.getElementById('seoDescription') as HTMLTextAreaElement).value,
266+
seoRobots: (document.getElementById('seoRobots') as HTMLSelectElement).value,
267+
csrfToken: csrfToken,
268+
};
269+
270+
const response = (await addPage(data)) as unknown as Response;
271+
if (typeof response.success === 'string') {
272+
pushNotification(response.success);
273+
setTimeout(() => {
274+
window.location.href = './pages';
275+
}, 2000);
276+
} else {
277+
pushErrorNotification(response.error || 'An error occurred');
278+
}
279+
});
280+
}
281+
};
282+
189283
/**
190284
* Handle edit page form
191285
*/

phpmyfaq/admin/assets/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
handleAddPage,
6464
handlePages,
6565
handleEditPage,
66+
handleTranslatePage,
6667
handleSaveFaqData,
6768
handleUpdateQuestion,
6869
handleRefreshAttachments,
@@ -190,6 +191,7 @@ document.addEventListener('DOMContentLoaded', async (): Promise<void> => {
190191
handleAddPage();
191192
handlePages();
192193
handleEditPage();
194+
handleTranslatePage();
193195

194196
// Initialize tooltips everywhere
195197
initializeTooltips();

phpmyfaq/assets/templates/admin/content/news.twig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
<div class="row">
2222
<div class="col-12">
23-
<table class="table table-hover align-middle">
23+
<table class="table table-hover align-middle border shadow">
2424
<thead class="thead-dark">
2525
<tr>
2626
<th>{{ ad_news_headline }}</th>
@@ -37,14 +37,14 @@
3737
<td>{{ newsItem.date|formatDate }}</td>
3838
<td>
3939
{% if permissionEditNews == true %}
40-
<a class="btn btn-primary" href="./news/edit/{{ newsItem.id }}">
40+
<a class="btn btn-primary btn-sm" href="./news/edit/{{ newsItem.id }}">
4141
<span title="{{ ad_news_update }}" class="bi bi-pencil"></span>
4242
</a>
4343
{% endif %}
4444
</td>
4545
<td>
4646
{% if permissionDeleteNews == true %}
47-
<a class="btn btn-danger" data-pmf-newsid="{{ newsItem.id }}" id="deleteNews">
47+
<a class="btn btn-danger btn-sm" data-pmf-newsid="{{ newsItem.id }}" id="deleteNews">
4848
<span title="{{ ad_news_delete }}" class="bi bi-trash"></span>
4949
</a>
5050
{% endif %}

phpmyfaq/assets/templates/admin/content/page.add.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@
129129
<i class="bi bi-check-lg"></i> {{ ad_page_add }}
130130
</button>
131131
<a href="./pages" class="btn btn-secondary">
132-
<i class="bi bi-x-lg"></i> {{ ad_entry_back }}
132+
<i class="bi bi-x-lg"></i> {{ msgCancel | translate }}
133133
</a>
134134
</div>
135135
</div>

0 commit comments

Comments
 (0)