@firecms/core 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/components/LicenseBanner.d.ts +24 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/contexts/LicenseStatusContext.d.ts +16 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/plugin_translations.d.ts +38 -0
- package/dist/core/pro_plugins.d.ts +40 -0
- package/dist/de-CBOjCvsy.js +707 -0
- package/dist/de-CBOjCvsy.js.map +1 -0
- package/dist/editor/extensions/Image.d.ts +11 -0
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/lazy_editor.d.ts +6 -0
- package/dist/editor-BSo_aS1I.js +4563 -0
- package/dist/editor-BSo_aS1I.js.map +1 -0
- package/dist/fr-DRfttcG0.js +707 -0
- package/dist/fr-DRfttcG0.js.map +1 -0
- package/dist/hi-DwieOZNf.js +707 -0
- package/dist/hi-DwieOZNf.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/useLicenseStatus.d.ts +16 -0
- package/dist/hooks/useProjectLog.d.ts +11 -6
- package/dist/hooks/useTranslation.d.ts +1 -1
- package/dist/index.es.js +2373 -10281
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +13166 -12269
- package/dist/index.umd.js.map +1 -1
- package/dist/it-l2KXtx9n.js +707 -0
- package/dist/it-l2KXtx9n.js.map +1 -0
- package/dist/pl-DPZrdKhi.js +709 -0
- package/dist/pl-DPZrdKhi.js.map +1 -0
- package/dist/pt-BoHpabw1.js +709 -0
- package/dist/pt-BoHpabw1.js.map +1 -0
- package/dist/types/analytics.d.ts +1 -1
- package/dist/types/firecms.d.ts +18 -1
- package/dist/types/firecms_context.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/license.d.ts +81 -0
- package/dist/types/plugins.d.ts +9 -1
- package/dist/types/translations.d.ts +45 -2
- package/dist/util/date_fns_locales.d.ts +18 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/lazy_eager.d.ts +3 -3
- package/dist/util/local_storage.d.ts +23 -0
- package/package.json +4 -5
- package/src/app/Scaffold.tsx +7 -0
- package/src/components/EntityCollectionView/utils.ts +4 -4
- package/src/components/LicenseBanner.tsx +287 -0
- package/src/components/index.tsx +1 -0
- package/src/contexts/LicenseStatusContext.tsx +50 -0
- package/src/core/DefaultAppBar.tsx +28 -16
- package/src/core/FireCMS.tsx +73 -47
- package/src/core/SideDialogs.tsx +44 -1
- package/src/core/index.tsx +1 -0
- package/src/core/plugin_translations.ts +87 -0
- package/src/core/pro_plugins.ts +79 -0
- package/src/editor/components/SlashCommandMenu.tsx +4 -1
- package/src/editor/extensions/Image.ts +17 -1
- package/src/editor/hooks/useProseMirror.ts +5 -0
- package/src/editor/index.ts +11 -1
- package/src/editor/lazy_editor.tsx +33 -0
- package/src/hooks/index.tsx +2 -0
- package/src/hooks/useBuildLocalConfigurationPersistence.tsx +9 -9
- package/src/hooks/useBuildModeController.tsx +6 -4
- package/src/hooks/useBuildNavigationController.tsx +47 -7
- package/src/hooks/useFireCMSContext.tsx +7 -3
- package/src/hooks/useLicenseStatus.tsx +32 -0
- package/src/hooks/useProjectLog.tsx +27 -9
- package/src/hooks/useTranslation.ts +3 -1
- package/src/i18n/FireCMSi18nProvider.tsx +169 -42
- package/src/locales/de.ts +23 -2
- package/src/locales/en.ts +28 -5
- package/src/locales/es.ts +23 -2
- package/src/locales/fr.ts +23 -2
- package/src/locales/hi.ts +23 -2
- package/src/locales/it.ts +23 -2
- package/src/locales/pl.ts +23 -2
- package/src/locales/pt.ts +23 -2
- package/src/preview/components/DatePreview.tsx +2 -4
- package/src/types/analytics.ts +3 -0
- package/src/types/firecms.tsx +19 -1
- package/src/types/firecms_context.tsx +9 -0
- package/src/types/index.ts +1 -0
- package/src/types/license.ts +90 -0
- package/src/types/plugins.tsx +10 -1
- package/src/types/translations.ts +45 -2
- package/src/util/date_fns_locales.ts +168 -0
- package/src/util/entity_cache.ts +10 -2
- package/src/util/index.ts +1 -0
- package/src/util/lazy_eager.tsx +30 -8
- package/src/util/local_storage.ts +52 -0
- package/src/util/navigation_utils.ts +6 -1
- package/src/util/useStorageUploadController.tsx +6 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import React, { useContext, useState } from "react";
|
|
2
|
+
import { Alert, Button, CloseIcon, IconButton } from "@firecms/ui";
|
|
3
|
+
import { AccessResponse, ProSuggestion } from "../types";
|
|
4
|
+
import { getLicenseSubscribeUrl, PRO_PLUGIN_KEYS } from "../core/pro_plugins";
|
|
5
|
+
import { CustomizationControllerContext } from "../contexts/CustomizationControllerContext";
|
|
6
|
+
import { useLicenseStatus } from "../hooks/useLicenseStatus";
|
|
7
|
+
import { useTranslation } from "../hooks/useTranslation";
|
|
8
|
+
|
|
9
|
+
const DISMISSED_STORAGE_KEY = "firecms_license_banner_dismissed";
|
|
10
|
+
const DISMISSED_SUGGESTIONS_STORAGE_KEY = "firecms_pro_suggestions_dismissed";
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @group Components
|
|
15
|
+
*/
|
|
16
|
+
export interface LicenseBannerProps {
|
|
17
|
+
/**
|
|
18
|
+
* Applied to the banner's outer element when there is something to show.
|
|
19
|
+
*/
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type BannerContent = {
|
|
25
|
+
state: string;
|
|
26
|
+
color: "info" | "warning";
|
|
27
|
+
message: string;
|
|
28
|
+
linkLabel: string;
|
|
29
|
+
href: string;
|
|
30
|
+
/**
|
|
31
|
+
* `session` hides it until the browser session ends, `forever` in this
|
|
32
|
+
* browser for good.
|
|
33
|
+
*/
|
|
34
|
+
dismissal: "none" | "session" | "forever";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where each PRO suggestion links: the plugin's docs, which say how to add it.
|
|
39
|
+
* The UTM parameters let the website count the clicks.
|
|
40
|
+
*/
|
|
41
|
+
const SUGGESTION_DOCS_URLS: Record<ProSuggestion["plugin"], string> = {
|
|
42
|
+
user_management: "https://firecms.co/docs/pro/user_management/?utm_source=firecms&utm_medium=cms_suggestion&utm_campaign=user_management",
|
|
43
|
+
entity_history: "https://firecms.co/docs/pro/entity_history/?utm_source=firecms&utm_medium=cms_suggestion&utm_campaign=entity_history"
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tells the user where their FireCMS PRO license stands: days left in the
|
|
48
|
+
* trial, or why PRO is paused (the trial ended, the key is not linked to this
|
|
49
|
+
* project, or the license pays for other projects). Renders nothing when
|
|
50
|
+
* licensed and while the status is unknown. When no license is needed, it
|
|
51
|
+
* shows only a PRO plugin the license check suggests for this project, if any.
|
|
52
|
+
*
|
|
53
|
+
* The default `Scaffold` already renders it above the main content. Place it
|
|
54
|
+
* yourself only in a custom layout.
|
|
55
|
+
*
|
|
56
|
+
* @group Components
|
|
57
|
+
*/
|
|
58
|
+
export function LicenseBanner({
|
|
59
|
+
className,
|
|
60
|
+
style
|
|
61
|
+
}: LicenseBannerProps) {
|
|
62
|
+
|
|
63
|
+
const licenseStatus = useLicenseStatus();
|
|
64
|
+
const { t, i18n } = useTranslation();
|
|
65
|
+
|
|
66
|
+
// The server answers `not_required` to every localhost and FireCMS Cloud
|
|
67
|
+
// request, whatever the app mounts, so a suggestion is only shown to an
|
|
68
|
+
// app that really runs no PRO plugin.
|
|
69
|
+
const plugins = useContext(CustomizationControllerContext)?.plugins;
|
|
70
|
+
const runsProPlugin = plugins?.some(plugin => PRO_PLUGIN_KEYS.includes(plugin.key)) ?? false;
|
|
71
|
+
|
|
72
|
+
// States dismissed in this browser session; a different state shows again.
|
|
73
|
+
const [dismissed, setDismissed] = useState<string[]>(() => readDismissed(sessionDismissals));
|
|
74
|
+
// Suggestions dismissed in this browser, for good.
|
|
75
|
+
const [dismissedForGood, setDismissedForGood] = useState<string[]>(() => readDismissed(browserDismissals));
|
|
76
|
+
|
|
77
|
+
// `language`, not `resolvedLanguage`: locale bundles are added after init,
|
|
78
|
+
// and `resolvedLanguage` stays on the English fallback when they are.
|
|
79
|
+
const content = licenseStatus
|
|
80
|
+
? buildBannerContent(licenseStatus, t, i18n?.language, runsProPlugin)
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
if (!content
|
|
84
|
+
|| (content.dismissal === "session" && dismissed.includes(content.state))
|
|
85
|
+
|| (content.dismissal === "forever" && dismissedForGood.includes(content.state)))
|
|
86
|
+
return null;
|
|
87
|
+
|
|
88
|
+
const dismiss = () => {
|
|
89
|
+
if (content.dismissal === "forever") {
|
|
90
|
+
const next = [...dismissedForGood, content.state];
|
|
91
|
+
writeDismissed(browserDismissals, next);
|
|
92
|
+
setDismissedForGood(next);
|
|
93
|
+
} else {
|
|
94
|
+
const next = [...dismissed, content.state];
|
|
95
|
+
writeDismissed(sessionDismissals, next);
|
|
96
|
+
setDismissed(next);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={className} style={style}>
|
|
102
|
+
<Alert
|
|
103
|
+
color={content.color}
|
|
104
|
+
size={"small"}
|
|
105
|
+
className={"text-sm"}
|
|
106
|
+
action={<div className={"flex flex-row items-center gap-1 flex-shrink-0"}>
|
|
107
|
+
<Button
|
|
108
|
+
variant={"text"}
|
|
109
|
+
size={"small"}
|
|
110
|
+
component={"a"}
|
|
111
|
+
href={content.href}
|
|
112
|
+
target={"_blank"}
|
|
113
|
+
rel={"noopener noreferrer"}>
|
|
114
|
+
{content.linkLabel}
|
|
115
|
+
</Button>
|
|
116
|
+
{content.dismissal !== "none" && <IconButton
|
|
117
|
+
size={"small"}
|
|
118
|
+
aria-label={t("close")}
|
|
119
|
+
onClick={dismiss}>
|
|
120
|
+
<CloseIcon size={"small"}/>
|
|
121
|
+
</IconButton>}
|
|
122
|
+
</div>}>
|
|
123
|
+
{content.message}
|
|
124
|
+
</Alert>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Translate = ReturnType<typeof useTranslation>["t"];
|
|
130
|
+
|
|
131
|
+
function buildBannerContent(status: AccessResponse,
|
|
132
|
+
t: Translate,
|
|
133
|
+
language: string | undefined,
|
|
134
|
+
runsProPlugin: boolean): BannerContent | null {
|
|
135
|
+
const href = getLicenseSubscribeUrl(status);
|
|
136
|
+
switch (status.licenseState) {
|
|
137
|
+
case "trial": {
|
|
138
|
+
const days = trialDaysLeft(status);
|
|
139
|
+
if (days === null) return null;
|
|
140
|
+
return {
|
|
141
|
+
state: "trial",
|
|
142
|
+
color: "info",
|
|
143
|
+
message: t("license_trial_banner", {
|
|
144
|
+
count: days,
|
|
145
|
+
days: String(days)
|
|
146
|
+
}),
|
|
147
|
+
linkLabel: t("license_get_license"),
|
|
148
|
+
href,
|
|
149
|
+
dismissal: "session"
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
case "expired": {
|
|
153
|
+
const date = formatDate(status.trialEndsAt, language);
|
|
154
|
+
return {
|
|
155
|
+
state: "expired",
|
|
156
|
+
color: "warning",
|
|
157
|
+
message: date
|
|
158
|
+
? t("license_expired_banner", { date })
|
|
159
|
+
: t("license_expired_banner_undated"),
|
|
160
|
+
linkLabel: t("license_get_license"),
|
|
161
|
+
href,
|
|
162
|
+
dismissal: "none"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
case "invalid_project":
|
|
166
|
+
return {
|
|
167
|
+
state: "invalid_project",
|
|
168
|
+
color: "warning",
|
|
169
|
+
message: status.projectId
|
|
170
|
+
? t("license_invalid_project_banner", { projectId: status.projectId })
|
|
171
|
+
: t("license_invalid_project_banner_unnamed"),
|
|
172
|
+
linkLabel: t("license_add_project_to_license"),
|
|
173
|
+
href,
|
|
174
|
+
dismissal: "none"
|
|
175
|
+
};
|
|
176
|
+
case "over_quota": {
|
|
177
|
+
const licensed = status.licensedProjects;
|
|
178
|
+
if (!isCount(licensed)) return null;
|
|
179
|
+
return {
|
|
180
|
+
state: "over_quota",
|
|
181
|
+
color: "warning",
|
|
182
|
+
message: t("license_over_quota_banner", {
|
|
183
|
+
count: licensed,
|
|
184
|
+
licensed: String(licensed)
|
|
185
|
+
}),
|
|
186
|
+
linkLabel: t("license_update_license"),
|
|
187
|
+
href,
|
|
188
|
+
dismissal: "none"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
case "not_required":
|
|
192
|
+
return runsProPlugin ? null : buildSuggestionContent(status.suggestion, t);
|
|
193
|
+
default:
|
|
194
|
+
// licensed, whitelisted, and servers too old to say
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildSuggestionContent(suggestion: ProSuggestion | undefined,
|
|
200
|
+
t: Translate): BannerContent | null {
|
|
201
|
+
if (!suggestion || typeof suggestion !== "object") return null;
|
|
202
|
+
let message: string;
|
|
203
|
+
switch (suggestion.plugin) {
|
|
204
|
+
case "user_management": {
|
|
205
|
+
const users = suggestion.users;
|
|
206
|
+
// The copy is plural only: one person is not a reason to suggest roles.
|
|
207
|
+
message = isCount(users) && users >= 2
|
|
208
|
+
? t("pro_suggestion_user_management", { users: String(Math.floor(users)) })
|
|
209
|
+
: t("pro_suggestion_user_management_uncounted");
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "entity_history":
|
|
213
|
+
message = t("pro_suggestion_entity_history");
|
|
214
|
+
break;
|
|
215
|
+
default:
|
|
216
|
+
// A plugin this version has no copy for.
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
state: `suggestion_${suggestion.plugin}`,
|
|
221
|
+
color: "info",
|
|
222
|
+
message,
|
|
223
|
+
linkLabel: t("pro_suggestion_learn_more"),
|
|
224
|
+
href: SUGGESTION_DOCS_URLS[suggestion.plugin],
|
|
225
|
+
dismissal: "forever"
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isCount(value: unknown): value is number {
|
|
230
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function trialDaysLeft(status: AccessResponse): number | null {
|
|
234
|
+
if (isCount(status.daysLeft)) return Math.ceil(status.daysLeft);
|
|
235
|
+
const end = status.trialEndsAt ? new Date(status.trialEndsAt).getTime() : NaN;
|
|
236
|
+
if (Number.isNaN(end)) return null;
|
|
237
|
+
return Math.max(0, Math.ceil((end - Date.now()) / DAY_MS));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function formatDate(iso: string | undefined, language: string | undefined): string | null {
|
|
241
|
+
if (!iso) return null;
|
|
242
|
+
const date = new Date(iso);
|
|
243
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
244
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
245
|
+
year: "numeric",
|
|
246
|
+
month: "long",
|
|
247
|
+
day: "numeric"
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
return new Intl.DateTimeFormat(language, options).format(date);
|
|
251
|
+
} catch {
|
|
252
|
+
return date.toLocaleDateString(undefined, options);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type DismissedStore = {
|
|
257
|
+
storage: () => Storage;
|
|
258
|
+
key: string;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const sessionDismissals: DismissedStore = {
|
|
262
|
+
storage: () => sessionStorage,
|
|
263
|
+
key: DISMISSED_STORAGE_KEY
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const browserDismissals: DismissedStore = {
|
|
267
|
+
storage: () => localStorage,
|
|
268
|
+
key: DISMISSED_SUGGESTIONS_STORAGE_KEY
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function readDismissed(store: DismissedStore): string[] {
|
|
272
|
+
try {
|
|
273
|
+
const value = store.storage().getItem(store.key);
|
|
274
|
+
const parsed = value ? JSON.parse(value) : [];
|
|
275
|
+
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
276
|
+
} catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeDismissed(store: DismissedStore, states: string[]) {
|
|
282
|
+
try {
|
|
283
|
+
store.storage().setItem(store.key, JSON.stringify(states));
|
|
284
|
+
} catch {
|
|
285
|
+
// Private mode or storage disabled: dismissed for this page view only.
|
|
286
|
+
}
|
|
287
|
+
}
|
package/src/components/index.tsx
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AccessResponse } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* License status of the enclosing `FireCMS`. `undefined` means there is no
|
|
6
|
+
* `FireCMS` above; `null` means the check has not answered (or was skipped).
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export const LicenseStatusContext = React.createContext<AccessResponse | null | undefined>(undefined);
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The same status, published outside React's tree.
|
|
13
|
+
*
|
|
14
|
+
* `useBuildNavigationController` runs in the app component that renders
|
|
15
|
+
* `<FireCMS>`, above it, so it cannot read the context. It still has to know
|
|
16
|
+
* when PRO is paused: it applies the plugins' collection, view and home page
|
|
17
|
+
* navigation contributions itself. `FireCMS` publishes here, and the navigation
|
|
18
|
+
* controller subscribes with `useSyncExternalStore`.
|
|
19
|
+
*
|
|
20
|
+
* One value per page: two `FireCMS` instances on one page would share it, which
|
|
21
|
+
* is harmless because both check the same project.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let currentStatus: AccessResponse | null = null;
|
|
25
|
+
const listeners = new Set<() => void>();
|
|
26
|
+
|
|
27
|
+
/** @internal */
|
|
28
|
+
export function getLicenseStatusSnapshot(): AccessResponse | null {
|
|
29
|
+
return currentStatus;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @internal */
|
|
33
|
+
export function getServerLicenseStatusSnapshot(): AccessResponse | null {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @internal */
|
|
38
|
+
export function subscribeLicenseStatus(listener: () => void): () => void {
|
|
39
|
+
listeners.add(listener);
|
|
40
|
+
return () => {
|
|
41
|
+
listeners.delete(listener);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @internal */
|
|
46
|
+
export function publishLicenseStatus(status: AccessResponse | null): void {
|
|
47
|
+
if (status === currentStatus) return;
|
|
48
|
+
currentStatus = status;
|
|
49
|
+
listeners.forEach(listener => listener());
|
|
50
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
3
|
import { Link, useNavigate } from "react-router-dom";
|
|
4
|
-
import { ErrorBoundary,
|
|
4
|
+
import { ErrorBoundary, LanguageToggle } from "../components";
|
|
5
5
|
import {
|
|
6
6
|
Avatar,
|
|
7
7
|
BrightnessMediumIcon,
|
|
@@ -20,6 +20,7 @@ import { useAuthController, useLargeLayout, useModeController, useNavigationCont
|
|
|
20
20
|
import { User } from "../types";
|
|
21
21
|
import { useApp } from "../app/useApp";
|
|
22
22
|
import { useBreadcrumbsController } from "../hooks/useBreadcrumbsController";
|
|
23
|
+
import { DrawerLogo } from "./DefaultDrawer";
|
|
23
24
|
|
|
24
25
|
export type DefaultAppBarProps<ADDITIONAL_PROPS = object> = {
|
|
25
26
|
|
|
@@ -119,32 +120,43 @@ export const DefaultAppBar = function DefaultAppBar({
|
|
|
119
120
|
"flex flex-row gap-2 px-4 items-center",
|
|
120
121
|
{
|
|
121
122
|
"pl-[19rem]": drawerOpen && largeLayout,
|
|
122
|
-
|
|
123
|
+
// The title has to sit at the same x whether or not a Drawer
|
|
124
|
+
// is mounted, so the bar reserves the collapsed rail's width
|
|
125
|
+
// in both cases. Only a pinned-open drawer moves it, in step
|
|
126
|
+
// with the drawer itself.
|
|
127
|
+
"pl-24": !(drawerOpen && largeLayout) && (hasDrawer || largeLayout),
|
|
123
128
|
"z-10": largeLayout,
|
|
124
129
|
"duration-100": drawerOpen && largeLayout,
|
|
125
130
|
},
|
|
126
131
|
className)}>
|
|
127
132
|
|
|
128
133
|
|
|
134
|
+
{/* Rendering DrawerLogo itself, rather than rebuilding its box here,
|
|
135
|
+
is what keeps the mark from moving when a screen with a drawer
|
|
136
|
+
follows one without: matching it by hand still left it 4px high,
|
|
137
|
+
because DrawerLogo centres a 40px box under `pt-4` instead of
|
|
138
|
+
centring in the 64px bar.
|
|
139
|
+
|
|
140
|
+
Gated on `largeLayout`, not on `lg:`: useLargeLayout resolves at
|
|
141
|
+
min-width 1025px while Tailwind's `lg:` starts at 1024px, and at
|
|
142
|
+
exactly 1024px the logo would render into a bar that had not
|
|
143
|
+
reserved room for it, landing on top of the title. */}
|
|
144
|
+
{navigation && !hasDrawer && largeLayout &&
|
|
145
|
+
<div className={"absolute left-0 top-0"}>
|
|
146
|
+
<DrawerLogo logo={logo} />
|
|
147
|
+
</div>}
|
|
148
|
+
|
|
129
149
|
{navigation && <div className="mr-2 hidden lg:block">
|
|
130
150
|
<Link
|
|
131
151
|
className="visited:text-inherit visited:dark:text-inherit block"
|
|
132
152
|
to={navigation?.basePath ?? "/"}
|
|
133
153
|
>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{typeof title === "string"
|
|
142
|
-
? <Typography variant="subtitle1"
|
|
143
|
-
noWrap>
|
|
144
|
-
{title}
|
|
145
|
-
</Typography>
|
|
146
|
-
: title}
|
|
147
|
-
</div>
|
|
154
|
+
{typeof title === "string"
|
|
155
|
+
? <Typography variant="subtitle1"
|
|
156
|
+
noWrap>
|
|
157
|
+
{title}
|
|
158
|
+
</Typography>
|
|
159
|
+
: title}
|
|
148
160
|
</Link>
|
|
149
161
|
</div>}
|
|
150
162
|
|
package/src/core/FireCMS.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useEffect, useMemo } from "react";
|
|
4
|
-
import { CenteredView
|
|
3
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { CenteredView } from "@firecms/ui";
|
|
5
|
+
import { AppliedBundles, applyPluginTranslations, createAppliedBundles } from "./plugin_translations";
|
|
6
|
+
import { filterPausedPlugins, isProPaused } from "./pro_plugins";
|
|
5
7
|
import { AuthController } from "../types";
|
|
6
8
|
import { CustomizationController, FireCMSContext, FireCMSPlugin, FireCMSProps, User } from "../types";
|
|
7
9
|
import { AuthControllerContext, ModeControllerProvider } from "../contexts";
|
|
@@ -23,6 +25,7 @@ import { AnalyticsContext } from "../contexts/AnalyticsContext";
|
|
|
23
25
|
import { useProjectLog } from "../hooks/useProjectLog";
|
|
24
26
|
import { BreadcrumbsProvider } from "../contexts/BreacrumbsContext";
|
|
25
27
|
import { InternalUserManagementContext } from "../contexts/InternalUserManagementContext";
|
|
28
|
+
import { LicenseStatusContext, publishLicenseStatus } from "../contexts/LicenseStatusContext";
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* If you are using independent components of the CMS
|
|
@@ -54,6 +57,7 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
54
57
|
components,
|
|
55
58
|
navigationController,
|
|
56
59
|
apiKey,
|
|
60
|
+
telemetry,
|
|
57
61
|
userManagement: _userManagement
|
|
58
62
|
} = props;
|
|
59
63
|
|
|
@@ -61,11 +65,42 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
61
65
|
console.warn("The `plugins` prop is deprecated in the FireCMS component. You should pass your plugins to `useBuildNavigationController` instead.");
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
const {
|
|
68
|
+
const { i18n } = useTranslation();
|
|
65
69
|
|
|
66
70
|
const modeController = useBuildModeController();
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
// Every plugin the app mounts. The license check is sent all of their keys:
|
|
73
|
+
// the server decides from them whether the project needs a license.
|
|
74
|
+
const allPlugins = navigationController.plugins ?? _pluginsProp;
|
|
75
|
+
|
|
76
|
+
const licenseStatus = useProjectLog({
|
|
77
|
+
apiKey,
|
|
78
|
+
telemetry,
|
|
79
|
+
authController,
|
|
80
|
+
dataSourceDelegate,
|
|
81
|
+
plugins: allPlugins
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The CMS always renders. While PRO is paused, PRO plugins drop out of
|
|
85
|
+
// everything below (providers, form, collection view and home page
|
|
86
|
+
// contributions); `useBuildNavigationController` drops their collection,
|
|
87
|
+
// view and navigation contributions from the status published below.
|
|
88
|
+
const plugins = useMemo(
|
|
89
|
+
() => filterPausedPlugins(allPlugins, licenseStatus),
|
|
90
|
+
[allPlugins, licenseStatus]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
publishLicenseStatus(licenseStatus);
|
|
95
|
+
if (licenseStatus?.message) {
|
|
96
|
+
if (isProPaused(licenseStatus)) console.warn(licenseStatus.message);
|
|
97
|
+
else console.debug(licenseStatus.message);
|
|
98
|
+
}
|
|
99
|
+
}, [licenseStatus]);
|
|
100
|
+
|
|
101
|
+
// The status belongs to this FireCMS; do not leave it to a later one.
|
|
102
|
+
useEffect(() => () => publishLicenseStatus(null), []);
|
|
103
|
+
|
|
69
104
|
const userManagement = plugins?.find(p => p.userManagement)?.userManagement
|
|
70
105
|
?? _userManagement
|
|
71
106
|
?? {
|
|
@@ -95,13 +130,6 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
95
130
|
onAnalyticsEvent
|
|
96
131
|
}), []);
|
|
97
132
|
|
|
98
|
-
const accessResponse = useProjectLog({
|
|
99
|
-
apiKey,
|
|
100
|
-
authController,
|
|
101
|
-
dataSourceDelegate,
|
|
102
|
-
plugins
|
|
103
|
-
});
|
|
104
|
-
|
|
105
133
|
/**
|
|
106
134
|
* Controller in charge of fetching and persisting data
|
|
107
135
|
*/
|
|
@@ -112,27 +140,42 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
112
140
|
authController
|
|
113
141
|
});
|
|
114
142
|
|
|
115
|
-
// Inject plugin translations into the
|
|
143
|
+
// Inject plugin translations into the live i18next instance.
|
|
144
|
+
//
|
|
145
|
+
// The work, and the reason repeat writes have to be suppressed, is in
|
|
146
|
+
// `applyPluginTranslations`. Paused plugins keep their strings: app code
|
|
147
|
+
// may render their components directly, and strings unlock nothing.
|
|
148
|
+
const appliedBundles = useRef<AppliedBundles>(createAppliedBundles());
|
|
149
|
+
const appliedTo = useRef<unknown>(null);
|
|
150
|
+
|
|
116
151
|
useEffect(() => {
|
|
117
152
|
if (!i18n) return;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
|
|
154
|
+
// A different instance has its own store and has been written nothing.
|
|
155
|
+
// Reset here rather than in an effect of its own, which would run after
|
|
156
|
+
// this one and wipe the record of what this pass just wrote.
|
|
157
|
+
if (appliedTo.current !== i18n) {
|
|
158
|
+
appliedTo.current = i18n;
|
|
159
|
+
appliedBundles.current = createAppliedBundles();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let active = true;
|
|
163
|
+
const isActive = () => active;
|
|
164
|
+
const applyFor = (language: string) => applyPluginTranslations({
|
|
165
|
+
i18n,
|
|
166
|
+
plugins: allPlugins,
|
|
167
|
+
language,
|
|
168
|
+
applied: appliedBundles.current,
|
|
169
|
+
isActive
|
|
130
170
|
});
|
|
131
|
-
}, [i18n, plugins]);
|
|
132
171
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
172
|
+
applyFor(i18n.language ?? "en");
|
|
173
|
+
i18n.on("languageChanged", applyFor);
|
|
174
|
+
return () => {
|
|
175
|
+
active = false;
|
|
176
|
+
i18n.off("languageChanged", applyFor);
|
|
177
|
+
};
|
|
178
|
+
}, [i18n, allPlugins]);
|
|
136
179
|
|
|
137
180
|
if (navigationController.navigationLoadingError) {
|
|
138
181
|
return (
|
|
@@ -154,26 +197,8 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
154
197
|
);
|
|
155
198
|
}
|
|
156
199
|
|
|
157
|
-
if (accessResponse?.blocked) {
|
|
158
|
-
return (
|
|
159
|
-
<CenteredView maxWidth={"md"} fullScreen={true} className={"flex flex-col gap-2"}>
|
|
160
|
-
{/* eslint-disable-next-line i18next/no-literal-string */}
|
|
161
|
-
<Typography variant={"h4"} gutterBottom>
|
|
162
|
-
{t("license_needed")}
|
|
163
|
-
</Typography>
|
|
164
|
-
<Typography>
|
|
165
|
-
{(() => {
|
|
166
|
-
const parts = t("license_description", { email: "%%EMAIL%%" }).split("%%EMAIL%%");
|
|
167
|
-
return <>{parts[0]}<a href={"mailto:hello@firecms.co"}>hello@firecms.co</a>{parts[1]}</>;
|
|
168
|
-
})()}
|
|
169
|
-
</Typography>
|
|
170
|
-
{accessResponse?.message &&
|
|
171
|
-
<Typography>{accessResponse?.message}</Typography>}
|
|
172
|
-
</CenteredView>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
200
|
return (
|
|
201
|
+
<LicenseStatusContext.Provider value={licenseStatus}>
|
|
177
202
|
<AnalyticsContext.Provider value={analyticsController}>
|
|
178
203
|
<CustomizationControllerContext.Provider value={customizationController}>
|
|
179
204
|
<UserConfigurationPersistenceContext.Provider
|
|
@@ -209,6 +234,7 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
209
234
|
</UserConfigurationPersistenceContext.Provider>
|
|
210
235
|
</CustomizationControllerContext.Provider>
|
|
211
236
|
</AnalyticsContext.Provider>
|
|
237
|
+
</LicenseStatusContext.Provider>
|
|
212
238
|
);
|
|
213
239
|
|
|
214
240
|
}
|
package/src/core/SideDialogs.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useContext, useEffect, useState } from "react";
|
|
1
|
+
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useSideDialogsController } from "../hooks";
|
|
3
3
|
import { EntitySidePanelProps, SideDialogPanelProps } from "../types";
|
|
4
4
|
import { Sheet } from "@firecms/ui";
|
|
@@ -67,6 +67,40 @@ export function SideDialogs() {
|
|
|
67
67
|
</>;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Menus, selects and popovers opened inside a side dialog are dismissed by Radix as
|
|
71
|
+
// soon as the pointer goes down, but the dialog underneath only makes up its mind on
|
|
72
|
+
// the click that follows, when that layer is already gone and the dialog looks like
|
|
73
|
+
// the topmost one. Without this, the click that dismisses an open menu also asks the
|
|
74
|
+
// panel to close, and an edited entity greets the user with the unsaved changes dialog.
|
|
75
|
+
const NESTED_LAYER_SELECTOR = [
|
|
76
|
+
"[data-radix-menu-content]",
|
|
77
|
+
"[data-radix-select-viewport]",
|
|
78
|
+
"[data-radix-popper-content-wrapper] [role=\"dialog\"]",
|
|
79
|
+
"[data-suggestion-menu=\"true\"]"
|
|
80
|
+
].join(",");
|
|
81
|
+
|
|
82
|
+
function useNestedLayerOpenOnPointerDown() {
|
|
83
|
+
const ref = useRef(false);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const onPointerDown = () => {
|
|
86
|
+
ref.current = Array.from(document.querySelectorAll(NESTED_LAYER_SELECTOR))
|
|
87
|
+
.some(element => window.getComputedStyle(element).visibility !== "hidden");
|
|
88
|
+
};
|
|
89
|
+
// the keyboard dismisses one layer at a time on its own, so a stale pointer
|
|
90
|
+
// interaction must not swallow a close requested with Escape
|
|
91
|
+
const onKeyDown = () => {
|
|
92
|
+
ref.current = false;
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
95
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
96
|
+
return () => {
|
|
97
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
98
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
return ref;
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
function SideDialogView({
|
|
71
105
|
offsetPosition,
|
|
72
106
|
panel
|
|
@@ -82,6 +116,8 @@ function SideDialogView({
|
|
|
82
116
|
|
|
83
117
|
const [pendingClose, setPendingClose] = useState(false);
|
|
84
118
|
|
|
119
|
+
const nestedLayerOpenOnPointerDown = useNestedLayerOpenOnPointerDown();
|
|
120
|
+
|
|
85
121
|
const widthRef = React.useRef<string | undefined>(panel?.width);
|
|
86
122
|
const width = widthRef.current;
|
|
87
123
|
|
|
@@ -144,6 +180,13 @@ function SideDialogView({
|
|
|
144
180
|
// Let Tiptap handle closing the menu first
|
|
145
181
|
return;
|
|
146
182
|
}
|
|
183
|
+
// this interaction started on top of a menu or popover: it dismissed
|
|
184
|
+
// that layer, it was not a request to close the panel. The flag is
|
|
185
|
+
// left alone because a single click asks to close more than once;
|
|
186
|
+
// the next pointer or key interaction recomputes it.
|
|
187
|
+
if (nestedLayerOpenOnPointerDown.current) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
147
190
|
onCloseRequest();
|
|
148
191
|
}
|
|
149
192
|
}}
|