Skip to content

Commit 7d753c7

Browse files
committed
feat: add phpMyFAQ recent news widget to admin dashboard
Add a full-width dashboard panel that fetches and displays recent news from phpmyfaq.de via a server-side API proxy. News content is parsed from markdown to HTML with relative links resolved against the phpmyfaq.de base URL. The widget is controlled by a new main.enableRecentNews configuration toggle (enabled by default).
1 parent 7fab64d commit 7d753c7

11 files changed

Lines changed: 447 additions & 2 deletions

File tree

phpmyfaq/admin/assets/src/dashboard.test.ts

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2-
import { getLatestVersion } from './dashboard';
2+
import { getLatestVersion, fetchRecentNews, parseNewsMarkdown } from './dashboard';
33

44
vi.mock('masonry-layout', () => ({
55
default: vi.fn(),
@@ -65,3 +65,160 @@ describe('dashboard getLatestVersion', () => {
6565
expect(alert?.textContent).not.toBeNull();
6666
});
6767
});
68+
69+
describe('parseNewsMarkdown', () => {
70+
it('converts markdown links with absolute URLs', () => {
71+
const result = parseNewsMarkdown('Check [our site](https://example.com) now');
72+
expect(result).toContain('<a href="https://example.com" target="_blank" rel="noopener noreferrer">our site</a>');
73+
});
74+
75+
it('resolves relative URLs against phpmyfaq.de base', () => {
76+
const result = parseNewsMarkdown('See [downloads](/download)');
77+
expect(result).toContain(
78+
'<a href="https://www.phpmyfaq.de/download" target="_blank" rel="noopener noreferrer">downloads</a>'
79+
);
80+
});
81+
82+
it('resolves relative URLs without leading slash', () => {
83+
const result = parseNewsMarkdown('See [news](news/latest)');
84+
expect(result).toContain(
85+
'<a href="https://www.phpmyfaq.de/news/latest" target="_blank" rel="noopener noreferrer">news</a>'
86+
);
87+
});
88+
89+
it('converts bold markdown', () => {
90+
const result = parseNewsMarkdown('This is **important** text');
91+
expect(result).toContain('<strong>important</strong>');
92+
});
93+
94+
it('converts italic markdown', () => {
95+
const result = parseNewsMarkdown('This is *italic* text');
96+
expect(result).toContain('<em>italic</em>');
97+
});
98+
99+
it('escapes HTML entities to prevent XSS', () => {
100+
const result = parseNewsMarkdown('<script>alert("xss")</script>');
101+
expect(result).not.toContain('<script>');
102+
expect(result).toContain('&lt;script&gt;');
103+
});
104+
105+
it('returns plain text when no markdown is present', () => {
106+
const result = parseNewsMarkdown('Just plain text');
107+
expect(result).toBe('Just plain text');
108+
});
109+
});
110+
111+
describe('fetchRecentNews', () => {
112+
beforeEach(() => {
113+
document.body.innerHTML = `
114+
<div id="pmf-news-loader" class="d-none"></div>
115+
<div id="pmf-recent-news"></div>
116+
`;
117+
});
118+
119+
afterEach(() => {
120+
document.body.innerHTML = '';
121+
vi.unstubAllGlobals();
122+
vi.clearAllMocks();
123+
});
124+
125+
it('does nothing when container element is missing', async () => {
126+
document.body.innerHTML = '';
127+
const fetchMock = vi.fn();
128+
vi.stubGlobal('fetch', fetchMock);
129+
130+
await fetchRecentNews();
131+
132+
expect(fetchMock).not.toHaveBeenCalled();
133+
});
134+
135+
it('renders news items on successful response', async () => {
136+
const fetchMock = vi.fn().mockResolvedValue({
137+
ok: true,
138+
json: vi.fn().mockResolvedValue({
139+
news: [
140+
{ date: '2026-03-10', content: 'phpMyFAQ **4.2** released' },
141+
{ date: '2026-03-01', content: 'Check [downloads](/download)' },
142+
],
143+
}),
144+
});
145+
vi.stubGlobal('fetch', fetchMock);
146+
147+
await fetchRecentNews();
148+
149+
const container = document.getElementById('pmf-recent-news') as HTMLDivElement;
150+
const items = container.querySelectorAll('li');
151+
expect(items.length).toBe(2);
152+
expect(items[0].querySelector('small')?.textContent).toBe('2026-03-10');
153+
expect(items[0].querySelector('span')?.innerHTML).toContain('<strong>4.2</strong>');
154+
expect(items[1].querySelector('span')?.innerHTML).toContain('href="https://www.phpmyfaq.de/download"');
155+
});
156+
157+
it('limits displayed news to 5 items', async () => {
158+
const news = Array.from({ length: 8 }, (_, i) => ({
159+
date: `2026-03-${String(i + 1).padStart(2, '0')}`,
160+
content: `News item ${i + 1}`,
161+
}));
162+
const fetchMock = vi.fn().mockResolvedValue({
163+
ok: true,
164+
json: vi.fn().mockResolvedValue({ news }),
165+
});
166+
vi.stubGlobal('fetch', fetchMock);
167+
168+
await fetchRecentNews();
169+
170+
const container = document.getElementById('pmf-recent-news') as HTMLDivElement;
171+
expect(container.querySelectorAll('li').length).toBe(5);
172+
});
173+
174+
it('shows fallback message when news array is empty', async () => {
175+
const fetchMock = vi.fn().mockResolvedValue({
176+
ok: true,
177+
json: vi.fn().mockResolvedValue({ news: [] }),
178+
});
179+
vi.stubGlobal('fetch', fetchMock);
180+
181+
await fetchRecentNews();
182+
183+
const container = document.getElementById('pmf-recent-news') as HTMLDivElement;
184+
expect(container.textContent).toContain('No recent news available.');
185+
});
186+
187+
it('shows error message when response is not ok', async () => {
188+
const fetchMock = vi.fn().mockResolvedValue({
189+
ok: false,
190+
json: vi.fn().mockResolvedValue({ error: 'disabled' }),
191+
});
192+
vi.stubGlobal('fetch', fetchMock);
193+
194+
await fetchRecentNews();
195+
196+
const container = document.getElementById('pmf-recent-news') as HTMLDivElement;
197+
expect(container.textContent).toContain('Could not load news.');
198+
});
199+
200+
it('shows error message on fetch failure', async () => {
201+
const fetchMock = vi.fn().mockRejectedValue(new Error('Network error'));
202+
vi.stubGlobal('fetch', fetchMock);
203+
204+
await fetchRecentNews();
205+
206+
const container = document.getElementById('pmf-recent-news') as HTMLDivElement;
207+
expect(container.textContent).toContain('Could not load news.');
208+
const loader = document.getElementById('pmf-news-loader') as HTMLDivElement;
209+
expect(loader.classList.contains('d-none')).toBe(true);
210+
});
211+
212+
it('hides loader after successful fetch', async () => {
213+
const fetchMock = vi.fn().mockResolvedValue({
214+
ok: true,
215+
json: vi.fn().mockResolvedValue({ news: [{ date: '2026-03-10', content: 'Test' }] }),
216+
});
217+
vi.stubGlobal('fetch', fetchMock);
218+
219+
await fetchRecentNews();
220+
221+
const loader = document.getElementById('pmf-news-loader') as HTMLDivElement;
222+
expect(loader.classList.contains('d-none')).toBe(true);
223+
});
224+
});

phpmyfaq/admin/assets/src/dashboard.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,101 @@ export const getLatestVersion = async (): Promise<void> => {
400400
}
401401
};
402402

403+
export const parseNewsMarkdown = (markdown: string): string => {
404+
const baseUrl = 'https://www.phpmyfaq.de';
405+
406+
let html = markdown
407+
// Escape HTML entities first to prevent XSS
408+
.replace(/&/g, '&amp;')
409+
.replace(/</g, '&lt;')
410+
.replace(/>/g, '&gt;')
411+
.replace(/"/g, '&quot;');
412+
413+
// Parse markdown links [text](url) — resolve relative URLs against base
414+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match: string, text: string, url: string) => {
415+
const resolvedUrl =
416+
url.startsWith('http://') || url.startsWith('https://')
417+
? url
418+
: `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
419+
return `<a href="${resolvedUrl}" target="_blank" rel="noopener noreferrer">${text}</a>`;
420+
});
421+
422+
// Bold **text**
423+
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
424+
425+
// Italic *text*
426+
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
427+
428+
return html;
429+
};
430+
431+
export const fetchRecentNews = async (): Promise<void> => {
432+
const container = document.getElementById('pmf-recent-news') as HTMLDivElement | null;
433+
const loader = document.getElementById('pmf-news-loader') as HTMLDivElement | null;
434+
435+
if (!container) {
436+
return;
437+
}
438+
439+
if (loader) {
440+
loader.classList.remove('d-none');
441+
}
442+
443+
try {
444+
const response = await fetch('./api/dashboard/news', {
445+
method: 'GET',
446+
cache: 'no-cache',
447+
headers: {
448+
'Content-Type': 'application/json',
449+
},
450+
redirect: 'follow',
451+
referrerPolicy: 'no-referrer',
452+
});
453+
454+
if (loader) {
455+
loader.classList.add('d-none');
456+
}
457+
458+
if (response.ok) {
459+
const data: { news?: { date: string; content: string }[] } = await response.json();
460+
const news = (data.news ?? []).slice(0, 5);
461+
462+
if (news.length === 0) {
463+
container.innerHTML = '<p class="text-muted mb-0">No recent news available.</p>';
464+
return;
465+
}
466+
467+
const list = document.createElement('ul');
468+
list.className = 'list-unstyled';
469+
470+
for (const item of news) {
471+
const li = document.createElement('li');
472+
li.className = 'mb-2';
473+
474+
const dateSpan = document.createElement('small');
475+
dateSpan.className = 'text-muted d-block border-bottom mb-2';
476+
dateSpan.textContent = item.date;
477+
478+
const contentSpan = document.createElement('span');
479+
contentSpan.innerHTML = parseNewsMarkdown(item.content);
480+
481+
li.appendChild(dateSpan);
482+
li.appendChild(contentSpan);
483+
list.appendChild(li);
484+
}
485+
486+
container.appendChild(list);
487+
} else {
488+
container.innerHTML = '<p class="text-muted mb-0">Could not load news.</p>';
489+
}
490+
} catch {
491+
if (loader) {
492+
loader.classList.add('d-none');
493+
}
494+
container.innerHTML = '<p class="text-muted mb-0">Could not load news.</p>';
495+
}
496+
};
497+
403498
export const handleVerificationModal = async (): Promise<void> => {
404499
const verificationModal = document.getElementById('verificationModal') as HTMLDivElement;
405500
const Translator = new TranslationService();

phpmyfaq/admin/assets/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
* @since 2019-12-20
1414
*/
1515

16-
import { getLatestVersion, renderVisitorCharts, renderTopTenCharts, handleVerificationModal } from './dashboard';
16+
import {
17+
fetchRecentNews,
18+
getLatestVersion,
19+
renderVisitorCharts,
20+
renderTopTenCharts,
21+
handleVerificationModal,
22+
} from './dashboard';
1723
import {
1824
handleClearRatings,
1925
handleClearVisits,
@@ -101,6 +107,7 @@ document.addEventListener('DOMContentLoaded', async (): Promise<void> => {
101107
await renderTopTenCharts();
102108
await getLatestVersion();
103109
await handleVerificationModal();
110+
await fetchRecentNews();
104111

105112
// User → User Management
106113
await handleUsers();

phpmyfaq/assets/templates/admin/dashboard.twig

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,26 @@
394394

395395
</section>
396396

397+
{% if hasRecentNews %}
398+
<section class="row mb-4">
399+
<div class="col-12">
400+
<div class="card shadow">
401+
<h5 class="card-header py-3">
402+
<i aria-hidden="true" class="bi bi-newspaper"></i> {{ 'ad_pmf_news' | translate }}
403+
</h5>
404+
<div class="card-body">
405+
<div class="d-flex justify-content-center d-none" id="pmf-news-loader">
406+
<div class="spinner-border text-secondary" role="status">
407+
<span class="visually-hidden">Loading...</span>
408+
</div>
409+
</div>
410+
<div id="pmf-recent-news"></div>
411+
</div>
412+
</div>
413+
</div>
414+
</section>
415+
{% endif %}
416+
397417
<!-- Verification Modal -->
398418
<div class="modal fade" id="verificationModal" tabindex="-1" aria-labelledby="verificationModalLabel"
399419
data-pmf-current-version="{{ currentVersionApp }}" aria-hidden="true">

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/DashboardController.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use phpMyFAQ\Faq;
2828
use phpMyFAQ\System;
2929
use phpMyFAQ\Translation;
30+
use Symfony\Component\HttpClient\HttpClient;
3031
use Symfony\Component\HttpFoundation\JsonResponse;
3132
use Symfony\Component\HttpFoundation\Request;
3233
use Symfony\Component\HttpFoundation\Response;
@@ -122,4 +123,35 @@ public function topTen(): JsonResponse
122123

123124
return $this->json(['error' => 'User tracking is disabled.'], 400);
124125
}
126+
127+
/**
128+
* @throws Exception
129+
*/
130+
#[Route(path: 'dashboard/news', name: 'admin.api.dashboard.news', methods: ['GET'])]
131+
public function news(): JsonResponse
132+
{
133+
$this->userIsAuthenticated();
134+
135+
if (!$this->configuration->get(item: 'main.enableRecentNews')) {
136+
return $this->json(['error' => 'Recent news is disabled.'], Response::HTTP_FORBIDDEN);
137+
}
138+
139+
try {
140+
$httpClient = HttpClient::create(['max_redirects' => 2, 'timeout' => 10]);
141+
$response = $httpClient->request('GET', 'https://www.phpmyfaq.de/api/news/recent');
142+
143+
if ($response->getStatusCode() === Response::HTTP_OK) {
144+
$data = $response->toArray(throw: false);
145+
if (isset($data['news']) && is_array($data['news'])) {
146+
$data['news'] = array_slice($data['news'], 0, 5);
147+
}
148+
149+
return $this->json($data);
150+
}
151+
152+
return $this->json(['error' => 'Failed to fetch news.'], Response::HTTP_BAD_GATEWAY);
153+
} catch (TransportExceptionInterface $exception) {
154+
return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_GATEWAY);
155+
}
156+
}
125157
}

phpmyfaq/src/phpMyFAQ/Controller/Administration/DashboardController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ public function index(Request $request): Response
9696
'lastBackupDate' => $backupInfo['lastBackupDate'],
9797
'isBackupOlderThan30Days' => $backupInfo['isBackupOlderThan30Days'],
9898
'adminDashboardLatestUsers' => $this->latestUsers->getList(limit: 5),
99+
'hasRecentNews' => $this->configuration->get(item: 'main.enableRecentNews'),
99100
];
100101

101102
if (version_compare($this->configuration->getVersion(), System::getVersion(), operator: '<')) {

phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,9 @@ public function up(OperationRecorder $recorder): void
851851
$recorder->addConfig('oauth2.refreshTokenTTL', 'P1M');
852852
$recorder->addConfig('oauth2.authCodeTTL', 'PT10M');
853853

854+
// Recent news widget
855+
$recorder->addConfig('main.enableRecentNews', 'true');
856+
854857
// OAuth2 storage tables
855858
if ($this->isMySql()) {
856859
$recorder->addSql(sprintf(

phpmyfaq/translations/language_en.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,7 @@
781781
$PMF_LANG['msgAllCatArticles'] = 'Records in this category';
782782
$PMF_LANG['msgTagSearch'] = 'Tagged entries';
783783
$PMF_LANG['ad_pmf_info'] = 'phpMyFAQ Information';
784+
$PMF_LANG['ad_pmf_news'] = 'phpMyFAQ News';
784785
$PMF_LANG['msgOnlineVersionCheck'] = 'Online version check';
785786
$PMF_LANG['ad_system_info'] = 'System Information';
786787

@@ -1623,6 +1624,7 @@
16231624

16241625
$PMF_LANG["msgCommentEditorHint"] = "You can use the text editor to format your comment with bold, italic, lists, and links.";
16251626
$LANG_CONF['main.enableCommentEditor'] = ["checkbox", "Enable WYSIWYG editor for comments (logged-in users only)"];
1627+
$LANG_CONF['main.enableRecentNews'] = ['checkbox', 'Enable recent phpMyFAQ news on dashboard'];
16261628
$PMF_LANG["msgToggleSidebar"] = "Toggle sidebar";
16271629
$PMF_LANG['msgFleschReadingEase'] = 'Readability Score';
16281630
$PMF_LANG['msgFleschTooltip'] = 'The Flesch Reading Ease score measures how easy your text is to read. Higher scores mean easier reading. Aim for 60-70 for general audiences.';

0 commit comments

Comments
 (0)