strapi-plugin-document-metadata 1.0.1

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.
Files changed (35) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +67 -0
  3. package/dist/_chunks/en-h9IAaoUJ.mjs +16 -0
  4. package/dist/_chunks/en-tDvQkVOI.js +16 -0
  5. package/dist/admin/index.js +311 -0
  6. package/dist/admin/index.mjs +312 -0
  7. package/dist/admin/src/components/DocumentMetadataCard/index.d.ts +10 -0
  8. package/dist/admin/src/components/DocumentMetadataGuard/index.d.ts +6 -0
  9. package/dist/admin/src/components/Initializer/index.d.ts +5 -0
  10. package/dist/admin/src/components/LastOpenedMetadataGuard/index.d.ts +10 -0
  11. package/dist/admin/src/components/LastOpenedMetadataLoader/index.d.ts +10 -0
  12. package/dist/admin/src/components/LastOpenedMetadataLoader/useLastOpened.d.ts +31 -0
  13. package/dist/admin/src/components/MetadataRow/index.d.ts +9 -0
  14. package/dist/admin/src/index.d.ts +10 -0
  15. package/dist/admin/src/pluginId.d.ts +1 -0
  16. package/dist/admin/src/utils/prefixKey.d.ts +2 -0
  17. package/dist/admin/src/utils/recentTimeFormatter.d.ts +16 -0
  18. package/dist/admin/src/utils/relativeDateFormatter.d.ts +30 -0
  19. package/dist/server/index.js +119 -0
  20. package/dist/server/index.mjs +120 -0
  21. package/dist/server/src/bootstrap.d.ts +5 -0
  22. package/dist/server/src/config/index.d.ts +5 -0
  23. package/dist/server/src/content-types/index.d.ts +2 -0
  24. package/dist/server/src/controllers/controller.d.ts +12 -0
  25. package/dist/server/src/controllers/index.d.ts +9 -0
  26. package/dist/server/src/destroy.d.ts +5 -0
  27. package/dist/server/src/index.d.ts +58 -0
  28. package/dist/server/src/middlewares/index.d.ts +2 -0
  29. package/dist/server/src/policies/index.d.ts +2 -0
  30. package/dist/server/src/register.d.ts +5 -0
  31. package/dist/server/src/routes/admin.d.ts +12 -0
  32. package/dist/server/src/routes/index.d.ts +14 -0
  33. package/dist/server/src/services/index.d.ts +19 -0
  34. package/dist/server/src/services/service.d.ts +35 -0
  35. package/package.json +78 -0
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 Felix Mau <me@felix.hamburg>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Strapi Plugin: Document Metadata
2
+
3
+ A Strapi plugin that displays entity metadata, with an option to include the **"last opened"** details.
4
+
5
+ ## ⏳ Installation
6
+
7
+ Install with NPM.
8
+
9
+ ```bash
10
+ npm install strapi-plugin-document-metadata --save
11
+ ```
12
+
13
+ Install with Yarn.
14
+
15
+ ```bash
16
+ yarn add strapi-plugin-document-metadata
17
+ ```
18
+
19
+ ## 🔧 Usage
20
+
21
+ ### 1. Configure the Plugin
22
+
23
+ Add the following configuration to your `config/plugins.ts` file. Create the file if it doesn’t already exist:
24
+
25
+ ```ts
26
+ export default {
27
+ // …
28
+ 'document-metadata': {
29
+ enabled: true,
30
+ },
31
+ };
32
+ ```
33
+
34
+ Then restart the app to apply the changes.
35
+
36
+ ### 2. Use in the Admin Panel
37
+
38
+ Once the plugin is installed, the **Document Metadata** card automatically appears when editing **collection types entries**. By default, it displays:
39
+
40
+ - Created at
41
+ - Created by
42
+ - Last updated at
43
+ - Last updated by
44
+
45
+ No additional setup is required for these fields.
46
+
47
+ #### Last Opened Metadata
48
+
49
+ To enable **Last Opened** tracking, add the following fields to your collection type using the Content-Type Builder:
50
+
51
+ | Field name | Type | Required |
52
+ | ---------- | -------- | -------- |
53
+ | `openedAt` | DateTime | ❌ |
54
+ | `openedBy` | String | ❌ |
55
+
56
+ Once these fields exist:
57
+
58
+ - The plugin automatically updates them when a document is opened.
59
+ - The metadata card will display the last opened date and user.
60
+
61
+ **Note:** To prevent these fields from showing up in the regular edit view, remove them under "Configure the view".
62
+
63
+ ## 📸 Screenshots
64
+
65
+ Below are screenshots from an example application showing the document metadata in the list view and when editing an entry.
66
+
67
+ <a href="./assets/content-type-builder.png"/><img src="./assets/content-type-builder-thumb.png" alt="Add openedAt and openedBy in the content type builder." /></a>&nbsp;&nbsp;<a href="./assets/content-manager-list-view.png"/><img src="./assets/content-manager-list-view-thumb.png" alt="Opened at and opened by in the list view." /></a>&nbsp;&nbsp;&nbsp;&nbsp;<a href="./assets/content-manager-first-time-opened.png"/><img src="./assets/content-manager-first-time-opened-thumb.png" alt="Document Metadata when opened for the first time." /></a>&nbsp;&nbsp;<a href="./assets/content-manager-last-time-opened.png"/><img src="./assets/content-manager-last-time-opened-thumb.png" alt="Document Metadata." /></a>
@@ -0,0 +1,16 @@
1
+ const en = {
2
+ "document-metadata.title": "Metadata",
3
+ "document-metadata.opened-at": "Opened at",
4
+ "document-metadata.opened-by": "by {username}",
5
+ "document-metadata.opened-first-time": "Opened for the first time.",
6
+ "document-metadata.updated-at": "Updated at",
7
+ "document-metadata.updated-by": "by {username}",
8
+ "document-metadata.created-at": "Created at",
9
+ "document-metadata.created-by": "by {username}",
10
+ "document-metadata.date.today": "Today, {formattedTime}",
11
+ "document-metadata.date.yesterday": "Yesterday, {formattedTime}",
12
+ "document-metadata.date.other": "{formattedDate}"
13
+ };
14
+ export {
15
+ en as default
16
+ };
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const en = {
4
+ "document-metadata.title": "Metadata",
5
+ "document-metadata.opened-at": "Opened at",
6
+ "document-metadata.opened-by": "by {username}",
7
+ "document-metadata.opened-first-time": "Opened for the first time.",
8
+ "document-metadata.updated-at": "Updated at",
9
+ "document-metadata.updated-by": "by {username}",
10
+ "document-metadata.created-at": "Created at",
11
+ "document-metadata.created-by": "by {username}",
12
+ "document-metadata.date.today": "Today, {formattedTime}",
13
+ "document-metadata.date.yesterday": "Yesterday, {formattedTime}",
14
+ "document-metadata.date.other": "{formattedDate}"
15
+ };
16
+ exports.default = en;
@@ -0,0 +1,311 @@
1
+ "use strict";
2
+ const react = require("react");
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const reactRouterDom = require("react-router-dom");
5
+ const admin = require("@strapi/strapi/admin");
6
+ const designSystem = require("@strapi/design-system");
7
+ const icons = require("@strapi/icons");
8
+ const reactIntl = require("react-intl");
9
+ const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
10
+ const v = glob[path];
11
+ if (v) {
12
+ return typeof v === "function" ? v() : Promise.resolve(v);
13
+ }
14
+ return new Promise((_, reject) => {
15
+ (typeof queueMicrotask === "function" ? queueMicrotask : setTimeout)(
16
+ reject.bind(
17
+ null,
18
+ new Error(
19
+ "Unknown variable dynamic import: " + path + (path.split("/").length !== segs ? ". Note that variables only represent file names one level deep." : "")
20
+ )
21
+ )
22
+ );
23
+ });
24
+ };
25
+ const PLUGIN_ID = "document-metadata";
26
+ const Initializer = ({ setPlugin }) => {
27
+ const ref = react.useRef(setPlugin);
28
+ react.useEffect(() => {
29
+ ref.current(PLUGIN_ID);
30
+ }, []);
31
+ return null;
32
+ };
33
+ const prefixKey = (key) => `${PLUGIN_ID}.${key}`;
34
+ const relativeDateFormatter = (date, textBuilder) => {
35
+ const today = /* @__PURE__ */ new Date();
36
+ if (today.toDateString() === date.toDateString()) {
37
+ return textBuilder.today(date.toLocaleTimeString());
38
+ }
39
+ const yesterday = /* @__PURE__ */ new Date();
40
+ yesterday.setDate(yesterday.getDate() - 1);
41
+ if (yesterday.toDateString() === date.toDateString()) {
42
+ return textBuilder.yesterday(date.toLocaleTimeString());
43
+ }
44
+ return textBuilder.other(date.toLocaleString());
45
+ };
46
+ const config$3 = {
47
+ /** The threshold in minutes to determine if a date is considered "recent". */
48
+ thresholdInMinutes: 60
49
+ };
50
+ const recentTimeFormatter = ({
51
+ date,
52
+ fallbackFormatter
53
+ }) => {
54
+ const now = /* @__PURE__ */ new Date();
55
+ const minutesElapsedSinceNow = minutesElapsed(now, date);
56
+ if (minutesElapsedSinceNow >= config$3.thresholdInMinutes) {
57
+ return fallbackFormatter(date);
58
+ }
59
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
60
+ return rtf.format(-minutesElapsedSinceNow, "minutes");
61
+ };
62
+ const minutesElapsed = (now, then) => {
63
+ const millisecondsPerMinute = 60 * 1e3;
64
+ return Math.floor((now.getTime() - then.getTime()) / millisecondsPerMinute);
65
+ };
66
+ const MetadataRow = ({ title, line1, line2 }) => {
67
+ return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Grid.Item, { direction: "column", alignItems: "stretch", children: [
68
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: title }),
69
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: [
70
+ line1,
71
+ /* @__PURE__ */ jsxRuntime.jsx("br", {}),
72
+ line2
73
+ ] })
74
+ ] });
75
+ };
76
+ var FetchStatus = /* @__PURE__ */ ((FetchStatus2) => {
77
+ FetchStatus2["Initial"] = "initial";
78
+ FetchStatus2["InProgress"] = "in-progress";
79
+ FetchStatus2["Success"] = "success";
80
+ FetchStatus2["Failure"] = "failure";
81
+ return FetchStatus2;
82
+ })(FetchStatus || {});
83
+ const config$2 = {
84
+ /** The configuration for the last-opened request. */
85
+ lastOpenedRequest: {
86
+ /** The path to fetch the last-opened metadata for the entity with the given `uid` and `documentId`. */
87
+ path: (uid, documentId) => `/document-metadata/last-opened/${uid}/${documentId}`
88
+ }
89
+ };
90
+ const useLastOpened = ({
91
+ uid,
92
+ documentId,
93
+ locale
94
+ }) => {
95
+ const fetchClient = admin.useFetchClient();
96
+ const [lastOpenedFetchState, setLastOpenedFetchState] = react.useState({
97
+ status: "initial"
98
+ /* Initial */
99
+ });
100
+ react.useEffect(() => {
101
+ const fetchLastOpened = async () => {
102
+ setLastOpenedFetchState({
103
+ status: "in-progress"
104
+ /* InProgress */
105
+ });
106
+ try {
107
+ const { data: lastOpened } = await fetchClient.get(
108
+ config$2.lastOpenedRequest.path(uid, documentId),
109
+ {
110
+ params: { locale }
111
+ }
112
+ );
113
+ setLastOpenedFetchState({ status: "success", lastOpened });
114
+ } catch (error) {
115
+ console.error(`Failed to fetch last-opened metadata: ${error}`);
116
+ setLastOpenedFetchState({ status: "failure", error });
117
+ }
118
+ };
119
+ fetchLastOpened();
120
+ }, [uid, documentId, locale]);
121
+ return lastOpenedFetchState;
122
+ };
123
+ const config$1 = {
124
+ /** The loading text to show while fetching data. */
125
+ loadingString: "…",
126
+ /** The non-breaking space character. */
127
+ nonBreakingSpace: " "
128
+ };
129
+ const LastOpenedMetadataLoader = ({
130
+ uid,
131
+ documentId,
132
+ locale
133
+ }) => {
134
+ const { formatMessage } = reactIntl.useIntl();
135
+ const translate = (key, values) => formatMessage({ id: prefixKey(key) }, values);
136
+ const lastOpenedFetchState = useLastOpened({ uid, documentId, locale });
137
+ switch (lastOpenedFetchState.status) {
138
+ case FetchStatus.Initial:
139
+ case FetchStatus.InProgress:
140
+ return /* @__PURE__ */ jsxRuntime.jsx(
141
+ MetadataRow,
142
+ {
143
+ title: translate("opened-at"),
144
+ line1: config$1.loadingString,
145
+ line2: config$1.nonBreakingSpace
146
+ }
147
+ );
148
+ case FetchStatus.Failure:
149
+ return null;
150
+ case FetchStatus.Success:
151
+ const lastOpened = lastOpenedFetchState.lastOpened;
152
+ if (!lastOpened.openedAt || !lastOpened.openedBy) {
153
+ return /* @__PURE__ */ jsxRuntime.jsx(MetadataRow, { title: translate("opened-at"), line1: translate("opened-first-time") });
154
+ }
155
+ const formattedOpenedAt = recentTimeFormatter({
156
+ date: new Date(lastOpened.openedAt),
157
+ fallbackFormatter: (date) => relativeDateFormatter(date, {
158
+ today: (formattedTime) => translate("date.today", { formattedTime }),
159
+ yesterday: (formattedTime) => translate("date.yesterday", { formattedTime }),
160
+ other: (formattedDate) => translate("date.other", { formattedDate })
161
+ })
162
+ });
163
+ const formattedOpenedBy = translate("opened-by", { username: lastOpened.openedBy });
164
+ return /* @__PURE__ */ jsxRuntime.jsx(
165
+ MetadataRow,
166
+ {
167
+ title: translate("opened-at"),
168
+ line1: formattedOpenedAt,
169
+ line2: formattedOpenedBy
170
+ }
171
+ );
172
+ }
173
+ };
174
+ const LastOpenedMetadataGuard = ({
175
+ uid,
176
+ document
177
+ }) => {
178
+ const hasLastOpenedFields = "openedAt" in document && "openedBy" in document;
179
+ if (!hasLastOpenedFields) {
180
+ return null;
181
+ }
182
+ return /* @__PURE__ */ jsxRuntime.jsx(LastOpenedMetadataLoader, { uid, documentId: document.documentId, locale: document.locale });
183
+ };
184
+ const DocumentMetadataCard = ({
185
+ collectionType,
186
+ uid,
187
+ documentId
188
+ }) => {
189
+ const { formatMessage } = reactIntl.useIntl();
190
+ const translate = (key, values) => formatMessage({ id: prefixKey(key) }, values);
191
+ const initialParams = { plugins: { i18n: { locale: void 0 } } };
192
+ const [queryParams, _] = admin.useQueryParams(initialParams);
193
+ const locale = queryParams.query.plugins.i18n.locale;
194
+ const { document } = admin.unstable_useDocument({ documentId, model: uid, collectionType, params: { locale } });
195
+ if (!document) {
196
+ return null;
197
+ }
198
+ const formatDate = (date) => recentTimeFormatter({
199
+ date: new Date(date),
200
+ fallbackFormatter: (date2) => relativeDateFormatter(date2, {
201
+ today: (formattedTime) => translate("date.today", { formattedTime }),
202
+ yesterday: (formattedTime) => translate("date.yesterday", { formattedTime }),
203
+ other: (formattedDate) => translate("date.other", { formattedDate })
204
+ })
205
+ });
206
+ const formatUsername = (user) => `${user.firstname} ${user.lastname}`;
207
+ let formattedUpdatedAt = formatDate(new Date(document.updatedAt));
208
+ let formattedUpdatedBy = document.updatedBy ? translate("updated-by", { username: formatUsername(document.updatedBy) }) : "";
209
+ let formattedCreatedAt = formatDate(new Date(document.createdAt));
210
+ let formattedCreatedBy = document.createdBy ? translate("created-by", { username: formatUsername(document.createdBy) }) : "";
211
+ return /* @__PURE__ */ jsxRuntime.jsx(
212
+ designSystem.Box,
213
+ {
214
+ width: "100%",
215
+ padding: "16px",
216
+ background: "neutral0",
217
+ hasRadius: true,
218
+ borderColor: "neutral200",
219
+ borderStyle: "solid",
220
+ borderWidth: "1px",
221
+ children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Grid.Root, { gap: "8px", gridCols: 1, children: [
222
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Grid.Item, { direction: "column", alignItems: "stretch", children: [
223
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: "8px", direction: "row", alignItems: "center", justifyContent: "space-between", children: [
224
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", textColor: "neutral600", children: /* @__PURE__ */ jsxRuntime.jsx(reactIntl.FormattedMessage, { id: prefixKey("title") }) }),
225
+ /* @__PURE__ */ jsxRuntime.jsx(icons.Paperclip, { fill: "neutral600", style: { marginRight: "4px" } })
226
+ ] }),
227
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Divider, { style: { marginTop: "6px", marginBottom: "4px" } })
228
+ ] }),
229
+ /* @__PURE__ */ jsxRuntime.jsx(LastOpenedMetadataGuard, { uid, document }),
230
+ /* @__PURE__ */ jsxRuntime.jsx(
231
+ MetadataRow,
232
+ {
233
+ title: translate("updated-at"),
234
+ line1: formattedUpdatedAt,
235
+ line2: formattedUpdatedBy
236
+ }
237
+ ),
238
+ /* @__PURE__ */ jsxRuntime.jsx(
239
+ MetadataRow,
240
+ {
241
+ title: translate("created-at"),
242
+ line1: formattedCreatedAt,
243
+ line2: formattedCreatedBy
244
+ }
245
+ )
246
+ ] })
247
+ }
248
+ );
249
+ };
250
+ const config = {
251
+ /**
252
+ * The supported collection type.
253
+ * We currently only support "collection-types", as "single-types" don't have their document ID as part of the URL.
254
+ */
255
+ supportedCollectionType: "collection-types",
256
+ /**
257
+ * The expected length of a valid document ID.
258
+ *
259
+ * > To address this limitation, Strapi 5 introduced documentId, a 24-character alphanumeric string, as a unique and
260
+ * > persistent identifier for a content entry, independent of its physical records.
261
+ *
262
+ * https://docs.strapi.io/cms/api/document-service
263
+ */
264
+ documentIdLength: 24
265
+ };
266
+ const DocumentMetadataGuard = () => {
267
+ const location = reactRouterDom.useLocation();
268
+ const urlPathComponents = location.pathname.split("/").filter(Boolean);
269
+ const [collectionType, uid, documentId] = urlPathComponents.slice(-3);
270
+ const isValidCollectionType = collectionType === config.supportedCollectionType;
271
+ const isValidUID = uid && uid.length;
272
+ const isValidDocumentId = documentId && documentId.length === config.documentIdLength;
273
+ if (!isValidCollectionType || !isValidUID || !isValidDocumentId) {
274
+ return null;
275
+ }
276
+ return /* @__PURE__ */ jsxRuntime.jsx(
277
+ DocumentMetadataCard,
278
+ {
279
+ collectionType,
280
+ uid,
281
+ documentId
282
+ }
283
+ );
284
+ };
285
+ const index = {
286
+ register(app) {
287
+ app.getPlugin("content-manager").injectComponent("editView", "right-links", {
288
+ name: "DocumentMetadataGuard",
289
+ Component: DocumentMetadataGuard
290
+ });
291
+ app.registerPlugin({
292
+ id: PLUGIN_ID,
293
+ initializer: Initializer,
294
+ isReady: false,
295
+ name: PLUGIN_ID
296
+ });
297
+ },
298
+ async registerTrads({ locales }) {
299
+ return Promise.all(
300
+ locales.map(async (locale) => {
301
+ try {
302
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("../_chunks/en-tDvQkVOI.js")) }), `./translations/${locale}.json`, 3);
303
+ return { data, locale };
304
+ } catch {
305
+ return { data: {}, locale };
306
+ }
307
+ })
308
+ );
309
+ }
310
+ };
311
+ module.exports = index;