Skip to content

Commit aa6103e

Browse files
[1.x] feat: announcements widget on admin dashboard (#4472)
* feat: announcements widget on admin dashboard Backport of #4471 for Flarum 1.x. Adds an Announcements widget to the admin dashboard that fetches and displays the latest posts from discuss.flarum.org. Results are cached for 14 days with manual cache-busting support. The widget can be hidden (persisted in localStorage) or disabled entirely via `flarum_announcements.disabled` in config.php. - `AnnouncementsFetcher` — HTTP fetch with enriched User-Agent - `ListAnnouncementsController` — admin-only `GET /api/flarum/announcements` with `?bust=1` - `RefreshAnnouncementsCommand` + `WeeklySchedule` — weekly scheduled cache warming - `AnnouncementsServiceProvider` — conditional registration - `AnnouncementsWidget`, `AnnouncementList`, `AnnouncementItem` frontend components - `AdminPayload` injection of `announcementsDisabled` flag * fix: PHP 7.3 compatibility and yarn format * Update framework/core/src/Announcements/Console/RefreshAnnouncementsCommand.php Co-authored-by: Davide Iadeluca <146922689+DavideIadeluca@users.noreply.github.com> --------- Co-authored-by: Davide Iadeluca <146922689+DavideIadeluca@users.noreply.github.com>
1 parent 7867d46 commit aa6103e

16 files changed

Lines changed: 842 additions & 0 deletions

framework/core/js/src/admin/AdminApplication.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface AdminApplicationData extends ApplicationData {
4242
displayNameDrivers: string[];
4343
slugDrivers: Record<string, string[]>;
4444
permissions: Record<string, string[]>;
45+
announcementsDisabled: boolean;
4546
}
4647

4748
export default class AdminApplication extends Application {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import app from '../../admin/app';
2+
import Component from '../../common/Component';
3+
import icon from '../../common/helpers/icon';
4+
import Link from '../../common/components/Link';
5+
import dayjs from 'dayjs';
6+
7+
export interface AnnouncementData {
8+
id: string;
9+
title: string;
10+
slug: string;
11+
commentCount: number;
12+
createdAt: string;
13+
isSticky: boolean;
14+
url: string;
15+
excerpt: string;
16+
authorName: string | null;
17+
avatarUrl: string | null;
18+
}
19+
20+
export interface IAnnouncementItemAttrs {
21+
announcement: AnnouncementData;
22+
}
23+
24+
export default class AnnouncementItem extends Component<IAnnouncementItemAttrs> {
25+
view() {
26+
const a = this.attrs.announcement;
27+
const date = dayjs(a.createdAt).format('MMM D, YYYY');
28+
29+
return (
30+
<Link className="AnnouncementItem" href={a.url} external={true} target="_blank">
31+
<div className="AnnouncementItem-body">
32+
<h3 className="AnnouncementItem-title">
33+
{a.isSticky && icon('fas fa-thumbtack', { className: 'AnnouncementItem-stickyIcon' })}
34+
{a.title}
35+
</h3>
36+
{a.excerpt && <p className="AnnouncementItem-excerpt">{a.excerpt}</p>}
37+
</div>
38+
<div className="AnnouncementItem-footer">
39+
<div className="AnnouncementItem-byline">
40+
{a.avatarUrl ? (
41+
<img className="AnnouncementItem-avatar" src={a.avatarUrl} alt={a.authorName ?? ''} loading="lazy" />
42+
) : (
43+
<span className="AnnouncementItem-avatarFallback">{icon('fas fa-user')}</span>
44+
)}
45+
<div className="AnnouncementItem-bylineText">
46+
{a.authorName && <span className="AnnouncementItem-authorName">{a.authorName}</span>}
47+
<span className="AnnouncementItem-meta">
48+
<span className="AnnouncementItem-date">{date}</span>
49+
<span className="AnnouncementItem-sep">·</span>
50+
<span className="AnnouncementItem-comments">
51+
{icon('fas fa-comment-alt')}
52+
{app.translator.trans('core.admin.announcements.comments_label', { count: a.commentCount })}
53+
</span>
54+
</span>
55+
</div>
56+
</div>
57+
<div className="AnnouncementItem-readMore">
58+
{app.translator.trans('core.admin.announcements.read_more')}
59+
{icon('fas fa-arrow-right')}
60+
</div>
61+
</div>
62+
</Link>
63+
);
64+
}
65+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Component from '../../common/Component';
2+
import AnnouncementItem, { AnnouncementData } from './AnnouncementItem';
3+
import LoadingIndicator from '../../common/components/LoadingIndicator';
4+
import icon from '../../common/helpers/icon';
5+
import Link from '../../common/components/Link';
6+
import app from '../../admin/app';
7+
8+
export interface IAnnouncementListAttrs {
9+
announcements: AnnouncementData[] | null;
10+
loading: boolean;
11+
error: boolean;
12+
onRetry: () => void;
13+
}
14+
15+
export default class AnnouncementList extends Component<IAnnouncementListAttrs> {
16+
view() {
17+
const { announcements, loading, error, onRetry } = this.attrs;
18+
19+
if (loading) {
20+
return (
21+
<div className="AnnouncementList-state">
22+
<LoadingIndicator />
23+
</div>
24+
);
25+
}
26+
27+
if (error) {
28+
return (
29+
<div className="AnnouncementList-state AnnouncementList-state--error">
30+
{icon('fas fa-exclamation-circle')}
31+
<p>{app.translator.trans('core.admin.announcements.load_error')}</p>
32+
<button className="Button" onclick={onRetry}>
33+
{app.translator.trans('core.admin.announcements.retry')}
34+
</button>
35+
</div>
36+
);
37+
}
38+
39+
if (!announcements?.length) {
40+
return (
41+
<div className="AnnouncementList-state AnnouncementList-state--empty">
42+
{icon('fas fa-bullhorn')}
43+
<p>{app.translator.trans('core.admin.announcements.empty')}</p>
44+
</div>
45+
);
46+
}
47+
48+
return (
49+
<div className="AnnouncementList">
50+
{announcements.map((a) => (
51+
<AnnouncementItem key={a.id} announcement={a} />
52+
))}
53+
<Link className="AnnouncementList-viewAll" href="https://discuss.flarum.org/t/blog" external={true} target="_blank">
54+
{icon('fas fa-external-link-alt')}
55+
{app.translator.trans('core.admin.announcements.view_all')}
56+
</Link>
57+
</div>
58+
);
59+
}
60+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import app from '../../admin/app';
2+
import DashboardWidget from './DashboardWidget';
3+
import AnnouncementList from './AnnouncementList';
4+
import type { AnnouncementData } from './AnnouncementItem';
5+
import type { IDashboardWidgetAttrs } from './DashboardWidget';
6+
import type Mithril from 'mithril';
7+
import icon from '../../common/helpers/icon';
8+
import Button from '../../common/components/Button';
9+
import Tooltip from '../../common/components/Tooltip';
10+
11+
const HIDDEN_KEY = 'flarum.announcements.hidden';
12+
13+
export default class AnnouncementsWidget extends DashboardWidget {
14+
announcements: AnnouncementData[] | null = null;
15+
loadError = false;
16+
loading = false;
17+
hidden = false;
18+
19+
oninit(vnode: Mithril.Vnode<IDashboardWidgetAttrs, this>) {
20+
super.oninit(vnode);
21+
this.hidden = localStorage.getItem(HIDDEN_KEY) === '1';
22+
23+
if (!this.hidden) {
24+
this.load();
25+
}
26+
}
27+
28+
className() {
29+
return 'AnnouncementsWidget' + (this.hidden ? ' AnnouncementsWidget--hidden' : '');
30+
}
31+
32+
async load(bust = false) {
33+
this.loading = true;
34+
this.loadError = false;
35+
m.redraw();
36+
37+
try {
38+
const url = app.forum.attribute('apiUrl') + '/flarum/announcements' + (bust ? '?bust=1' : '');
39+
const data = (await app.request({ method: 'GET', url })) as unknown as AnnouncementData[];
40+
this.announcements = data;
41+
} catch (e) {
42+
this.loadError = true;
43+
} finally {
44+
this.loading = false;
45+
m.redraw();
46+
}
47+
}
48+
49+
toggleHidden() {
50+
this.hidden = !this.hidden;
51+
52+
if (this.hidden) {
53+
localStorage.setItem(HIDDEN_KEY, '1');
54+
} else {
55+
localStorage.removeItem(HIDDEN_KEY);
56+
if (!this.announcements && !this.loading) {
57+
this.load();
58+
}
59+
}
60+
61+
m.redraw();
62+
}
63+
64+
content() {
65+
return (
66+
<>
67+
<div className="AnnouncementsWidget-header">
68+
<h2 className="AnnouncementsWidget-title">
69+
{icon('fas fa-bullhorn')}
70+
{app.translator.trans('core.admin.announcements.title')}
71+
<Tooltip text={app.translator.trans('core.admin.announcements.about')}>
72+
<span className="AnnouncementsWidget-info">{icon('fas fa-info-circle')}</span>
73+
</Tooltip>
74+
</h2>
75+
<div className="AnnouncementsWidget-controls">
76+
{!this.hidden && (
77+
<Tooltip text={app.translator.trans('core.admin.announcements.refresh')}>
78+
<Button
79+
className="Button Button--icon"
80+
icon={this.loading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'}
81+
disabled={this.loading}
82+
onclick={() => this.load(true)}
83+
/>
84+
</Tooltip>
85+
)}
86+
<Tooltip text={app.translator.trans(this.hidden ? 'core.admin.announcements.show' : 'core.admin.announcements.hide')}>
87+
<Button className="Button Button--icon" icon={this.hidden ? 'fas fa-eye' : 'fas fa-eye-slash'} onclick={() => this.toggleHidden()} />
88+
</Tooltip>
89+
</div>
90+
</div>
91+
{!this.hidden && (
92+
<AnnouncementList announcements={this.announcements} loading={this.loading} error={this.loadError} onRetry={() => this.load()} />
93+
)}
94+
</>
95+
);
96+
}
97+
}

framework/core/js/src/admin/components/DashboardPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ItemList from '../../common/utils/ItemList';
55
import AdminPage from './AdminPage';
66
import type { Children } from 'mithril';
77
import DebugWarningWidget from './DebugWarningWidget';
8+
import AnnouncementsWidget from './AnnouncementWidget';
89

910
export default class DashboardPage extends AdminPage {
1011
headerInfo() {
@@ -31,6 +32,10 @@ export default class DashboardPage extends AdminPage {
3132

3233
items.add('extensions', <ExtensionsWidget />, 10);
3334

35+
if (!app.data.announcementsDisabled) {
36+
items.add('announcements', <AnnouncementsWidget />, 29);
37+
}
38+
3439
return items;
3540
}
3641
}

framework/core/less/admin.less

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
@import "admin/MailPage";
1616
@import "admin/NoJs";
1717
@import "admin/UsersListPage";
18+
@import "admin/AnnouncementsPage";

0 commit comments

Comments
 (0)